From 883df246ad2edf7b32b3f2a391cfca0cb3083f1d Mon Sep 17 00:00:00 2001 From: Pavlo Getta Date: Thu, 25 Jun 2026 08:50:07 +0200 Subject: [PATCH 1/6] Support querying and displaying circuits by derivation type Expose circuit derivations on GET /circuit, derived from the existing Derivation table (no migration, no denormalized column). - Filter (always available), both directions mirroring the read fields: generated_derivation__derivation_type (circuit is the generated/derived entity) and used_derivation__derivation_type (circuit is the used/source entity), each with an __in variant. No source-type restriction, so e.g. an emodel->circuit emodel_circuit derivation matches on the generated side. - Read columns (opt-in via ?expand=...): generated_derivations and used_derivations, expandable independently. Unexpanded directions are omitted; expanded but empty serialize as []. Load-aware properties keep this safe under raiseload("*") via two viewonly relationships on Circuit. --- app/db/model.py | 33 ++++++++ app/filters/circuit.py | 25 +++++- app/queries/factory.py | 13 +++ app/schemas/circuit.py | 31 +++++++- app/service/circuit.py | 51 ++++++++++-- tests/test_circuit.py | 175 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 316 insertions(+), 12 deletions(-) diff --git a/app/db/model.py b/app/db/model.py index 91931a44a..cd7b6e221 100644 --- a/app/db/model.py +++ b/app/db/model.py @@ -1919,6 +1919,39 @@ class Circuit(ScientificArtifact, NameDescriptionVectorMixin): # calibration_data (multiple entities): ... + # View-only relationships to the Derivation rows that reference this circuit. + # They are loaded on demand (see app/service/circuit.py `expand`) and read through the + # load-aware properties below, so an un-expanded direction serializes as null instead of + # tripping `raiseload`. + derivations_as_generated: Mapped[list["Derivation"]] = relationship( + "Derivation", + primaryjoin=lambda: Circuit.id == Derivation.generated_id, + foreign_keys=lambda: [Derivation.generated_id], + viewonly=True, + overlaps="generated", + ) + derivations_as_used: Mapped[list["Derivation"]] = relationship( + "Derivation", + primaryjoin=lambda: Circuit.id == Derivation.used_id, + foreign_keys=lambda: [Derivation.used_id], + viewonly=True, + overlaps="used", + ) + + @property + def generated_derivations(self) -> list["Derivation"] | None: + """Derivations where this circuit is the generated entity, or None if not expanded.""" + if "derivations_as_generated" in sa.inspect(self).unloaded: + return None + return self.derivations_as_generated + + @property + def used_derivations(self) -> list["Derivation"] | None: + """Derivations where this circuit is the used entity, or None if not expanded.""" + if "derivations_as_used" in sa.inspect(self).unloaded: + return None + return self.derivations_as_used + @declared_attr.directive @classmethod def __table_args__(cls): # noqa: D105, PLW3201 diff --git a/app/filters/circuit.py b/app/filters/circuit.py index 6eb3c93ba..2b28fc176 100644 --- a/app/filters/circuit.py +++ b/app/filters/circuit.py @@ -3,14 +3,24 @@ from fastapi_filter import with_prefix -from app.db.model import Circuit -from app.db.types import CircuitBuildCategory, CircuitScale, TargetSimulator +from app.db.model import Circuit, Derivation +from app.db.types import CircuitBuildCategory, CircuitScale, DerivationType, TargetSimulator from app.dependencies.filter import FilterDepends from app.filters.base import CustomFilter from app.filters.common import IdFilterMixin, ILikeSearchFilterMixin, NameFilterMixin from app.filters.scientific_artifact import ScientificArtifactFilter +class NestedDerivationFilter(CustomFilter): + """Filter circuits by derivation type, on either the generated or used side.""" + + derivation_type: DerivationType | None = None + derivation_type__in: list[DerivationType] | None = None + + class Constants(CustomFilter.Constants): + model = Derivation + + class CircuitFilterMixin: scale: CircuitScale | None = None scale__in: list[CircuitScale] | None = None @@ -56,6 +66,17 @@ class CircuitFilter( number_connections__lte: int | None = None number_connections__gte: int | None = None + # derivations where the circuit is the generated (derived) entity: "how it was derived" + generated_derivation: Annotated[ + NestedDerivationFilter | None, + FilterDepends(with_prefix("generated_derivation", NestedDerivationFilter)), + ] = None + # derivations where the circuit is the used (source) entity: "what was derived from it" + used_derivation: Annotated[ + NestedDerivationFilter | None, + FilterDepends(with_prefix("used_derivation", NestedDerivationFilter)), + ] = None + order_by: list[str] = ["-creation_date"] # noqa: RUF012 class Constants(ScientificArtifactFilter.Constants): diff --git a/app/queries/factory.py b/app/queries/factory.py index 0917857cb..bf9894723 100644 --- a/app/queries/factory.py +++ b/app/queries/factory.py @@ -10,6 +10,7 @@ CellMorphologyProtocol, Circuit, Contribution, + Derivation, EMCellMesh, EMDenseReconstructionDataset, EModel, @@ -94,6 +95,8 @@ def _get_alias[T: type[DeclarativeBase]](db_cls: T, name: str | None = None) -> used_alias = _get_alias(Entity, "used") generated_alias = _get_alias(Entity, "generated") circuit_alias = _get_alias(Circuit) + generated_derivation_alias = _get_alias(Derivation, "generated_derivation") + used_derivation_alias = _get_alias(Derivation, "used_derivation") ion_channel_alias = _get_alias(IonChannel) em_dense_reconstruction_dataset_alias = _get_alias(EMDenseReconstructionDataset) ion_channel_model_alias = _get_alias(IonChannelModel, "ion_channel_model") @@ -274,6 +277,16 @@ def _get_alias[T: type[DeclarativeBase]](db_cls: T, name: str | None = None) -> ) == circuit_alias.id, ), + # circuits filtered by derivation type on the generated (derived) side + "generated_derivation": lambda q: q.outerjoin( + generated_derivation_alias, + db_model_class.id == generated_derivation_alias.generated_id, + ), + # circuits filtered by derivation type on the used (source) side + "used_derivation": lambda q: q.outerjoin( + used_derivation_alias, + db_model_class.id == used_derivation_alias.used_id, + ), "used": lambda q: q.outerjoin( Usage, db_model_class.id == Usage.usage_activity_id ).outerjoin(used_alias, Usage.usage_entity_id == used_alias.id), diff --git a/app/schemas/circuit.py b/app/schemas/circuit.py index e510f992f..61052a14b 100644 --- a/app/schemas/circuit.py +++ b/app/schemas/circuit.py @@ -1,7 +1,8 @@ import uuid -from app.db.types import CircuitBuildCategory, CircuitScale, TargetSimulator -from app.schemas.base import NameDescriptionMixin +from app.db.types import CircuitBuildCategory, CircuitScale, DerivationType, TargetSimulator +from app.schemas.base import NameDescriptionMixin, Schema +from app.schemas.entity import NestedEntityRead from app.schemas.scientific_artifact import ScientificArtifactCreate, ScientificArtifactRead from app.schemas.utils import make_update_schema @@ -28,6 +29,32 @@ class CircuitRead(CircuitBaseMixin, ScientificArtifactRead): pass +class CircuitGeneratedDerivationRead(Schema): + """A derivation where the circuit is the generated (derived) entity.""" + + used: NestedEntityRead + derivation_type: DerivationType + label: str | None = None + + +class CircuitUsedDerivationRead(Schema): + """A derivation where the circuit is the used (source) entity.""" + + generated: NestedEntityRead + derivation_type: DerivationType + label: str | None = None + + +class CircuitExpandedRead(CircuitRead): + """Circuit read schema with on-demand derivation lists (see `expand` query param). + + A direction that was not expanded serializes as ``null`` (load-aware property on the model). + """ + + generated_derivations: list[CircuitGeneratedDerivationRead] | None = None + used_derivations: list[CircuitUsedDerivationRead] | None = None + + class CircuitCreate(CircuitBaseMixin, ScientificArtifactCreate): pass diff --git a/app/service/circuit.py b/app/service/circuit.py index 3be0401dd..1fb7f9f27 100644 --- a/app/service/circuit.py +++ b/app/service/circuit.py @@ -1,13 +1,17 @@ import uuid -from typing import TYPE_CHECKING +from enum import StrEnum, auto +from functools import partial +from typing import TYPE_CHECKING, Annotated import sqlalchemy as sa +from fastapi import Query from sqlalchemy.orm import aliased, joinedload, raiseload, selectinload from app.db.model import ( Agent, Circuit, Contribution, + Derivation, Person, Subject, ) @@ -31,6 +35,7 @@ from app.schemas.circuit import ( CircuitAdminUpdate, CircuitCreate, + CircuitExpandedRead, CircuitRead, CircuitUserUpdate, ) @@ -41,8 +46,15 @@ from app.filters.base import Aliases -def _load(query: sa.Select): - return query.options( +class ExpandableAttribute(StrEnum): + """Derivation lists that can be loaded on demand via the `expand` query param.""" + + generated_derivations = auto() + used_derivations = auto() + + +def _load(query: sa.Select, *, expand: set[ExpandableAttribute] | None = None): + query = query.options( joinedload(Circuit.license), joinedload(Circuit.subject).options( joinedload(Subject.species), @@ -58,6 +70,15 @@ def _load(query: sa.Select): selectinload(Circuit.assets), raiseload("*"), ) + if expand and ExpandableAttribute.generated_derivations in expand: + query = query.options( + selectinload(Circuit.derivations_as_generated).joinedload(Derivation.used) + ) + if expand and ExpandableAttribute.used_derivations in expand: + query = query.options( + selectinload(Circuit.derivations_as_used).joinedload(Derivation.generated) + ) + return query def read_one( @@ -149,12 +170,15 @@ def _read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: set[ExpandableAttribute] | None, check_authorized_project: bool, -) -> ListResponse[CircuitRead]: +) -> ListResponse[CircuitRead | CircuitExpandedRead]: subject_alias = aliased(Subject, flat=True) agent_alias = aliased(Agent, flat=True) created_by_alias = aliased(Person, flat=True) updated_by_alias = aliased(Person, flat=True) + generated_derivation_alias = aliased(Derivation, flat=True) + used_derivation_alias = aliased(Derivation, flat=True) aliases: Aliases = { Subject: subject_alias, @@ -165,6 +189,10 @@ def _read_many( "created_by": created_by_alias, "updated_by": updated_by_alias, }, + Derivation: { + "generated_derivation": generated_derivation_alias, + "used_derivation": used_derivation_alias, + }, } facet_keys = [ "brain_region", @@ -177,6 +205,8 @@ def _read_many( filter_keys = [ "subject", *facet_keys, + "generated_derivation", + "used_derivation", ] name_to_facet_query_params, filter_joins = query_params_factory( db_model_class=Circuit, @@ -184,6 +214,7 @@ def _read_many( filter_keys=filter_keys, aliases=aliases, ) + response_schema_class = CircuitExpandedRead if expand else CircuitRead return router_read_many( db=db, filter_model=filter_model, @@ -193,10 +224,10 @@ def _read_many( facets=facets, name_to_facet_query_params=name_to_facet_query_params, apply_filter_query_operations=None, - apply_data_query_operations=_load, + apply_data_query_operations=partial(_load, expand=expand), aliases=aliases, pagination_request=pagination_request, - response_schema_class=CircuitRead, + response_schema_class=response_schema_class, authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, @@ -211,7 +242,8 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, -) -> ListResponse[CircuitRead]: + expand: Annotated[set[ExpandableAttribute] | None, Query()] = None, +) -> ListResponse[CircuitRead | CircuitExpandedRead]: return _read_many( user_context=user_context, db=db, @@ -220,6 +252,7 @@ def read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -232,7 +265,8 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, -) -> ListResponse[CircuitRead]: + expand: Annotated[set[ExpandableAttribute] | None, Query()] = None, +) -> ListResponse[CircuitRead | CircuitExpandedRead]: return _read_many( user_context=user_context, db=db, @@ -241,6 +275,7 @@ def admin_read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) diff --git a/tests/test_circuit.py b/tests/test_circuit.py index 30c556676..2d45332ba 100644 --- a/tests/test_circuit.py +++ b/tests/test_circuit.py @@ -2,6 +2,7 @@ from app.db.model import ( Circuit, + Derivation, ExternalUrl, Publication, ScientificArtifactExternalUrlLink, @@ -10,6 +11,7 @@ from app.db.types import ( CircuitBuildCategory, CircuitScale, + DerivationType, EntityType, ExternalSource, PublicationType, @@ -389,3 +391,176 @@ def test_filtering(client, root_circuit, models): params={"lifecycle_status": "active", "root_circuit_id": str(root_circuit.id)}, ).json()["data"] assert len(data) == len(models) + + +def _add_derivation( + db, + *, + used_id, + generated_id, + person_id, + derivation_type=DerivationType.circuit_extraction, + label=None, +): + return add_db( + db, + Derivation( + used_id=used_id, + generated_id=generated_id, + derivation_type=derivation_type, + label=label, + created_by_id=person_id, + updated_by_id=person_id, + ), + ) + + +def test_filter_by_derivation_type(db, client, root_circuit, circuit, public_circuit, person_id): + """Filter circuits by derivation type on both the generated and used sides.""" + # circuit is derived from root_circuit via extraction; public_circuit via rewiring. + # => root_circuit is the `used` (source) side of both derivations. + _add_derivation( + db, + used_id=root_circuit.id, + generated_id=circuit.id, + person_id=person_id, + derivation_type=DerivationType.circuit_extraction, + ) + _add_derivation( + db, + used_id=root_circuit.id, + generated_id=public_circuit.id, + person_id=person_id, + derivation_type=DerivationType.circuit_rewiring, + ) + + def ids(params): + return { + d["id"] for d in assert_request(client.get, url=ROUTE, params=params).json()["data"] + } + + # --- generated side: "how this circuit was derived" + assert ids({"generated_derivation__derivation_type": "circuit_extraction"}) == {str(circuit.id)} + assert ids({"generated_derivation__derivation_type": "circuit_rewiring"}) == { + str(public_circuit.id) + } + matched = ids( + {"generated_derivation__derivation_type__in": ["circuit_extraction", "circuit_rewiring"]} + ) + assert matched == {str(circuit.id), str(public_circuit.id)} + # the underived root_circuit is not on the generated side of any derivation + assert str(root_circuit.id) not in matched + assert ids({"generated_derivation__derivation_type": "circuit_customization"}) == set() + + # --- used side: "what was derived from this circuit" + assert ids({"used_derivation__derivation_type": "circuit_extraction"}) == {str(root_circuit.id)} + assert ids({"used_derivation__derivation_type": "circuit_rewiring"}) == {str(root_circuit.id)} + assert ids( + {"used_derivation__derivation_type__in": ["circuit_extraction", "circuit_rewiring"]} + ) == {str(root_circuit.id)} + # the derived children are not on the used side of these derivations + assert ids({"used_derivation__derivation_type": "circuit_customization"}) == set() + + # the filter alone does not add derivation columns to the response + data = assert_request( + client.get, + url=ROUTE, + params={"generated_derivation__derivation_type": "circuit_extraction"}, + ).json()["data"] + assert "generated_derivations" not in data[0] + assert "used_derivations" not in data[0] + + +def test_filter_by_derivation_type_non_circuit_source(db, client, circuit, emodel_id, person_id): + """The filter has no source-type restriction: an emodel->circuit derivation matches.""" + _add_derivation( + db, + used_id=emodel_id, + generated_id=circuit.id, + person_id=person_id, + derivation_type=DerivationType.emodel_circuit, + ) + data = assert_request( + client.get, url=ROUTE, params={"generated_derivation__derivation_type": "emodel_circuit"} + ).json()["data"] + assert {d["id"] for d in data} == {str(circuit.id)} + + +def test_expand_derivations(db, client, root_circuit, circuit, person_id): + """`expand` opts into derivation lists, per direction, independently.""" + _add_derivation( + db, + used_id=root_circuit.id, + generated_id=circuit.id, + person_id=person_id, + derivation_type=DerivationType.circuit_extraction, + label="extracted", + ) + + def get_by_id(entity_id, params=None): + data = assert_request(client.get, url=ROUTE, params=params or {}).json()["data"] + return next(d for d in data if d["id"] == str(entity_id)) + + # no expand -> fields are absent entirely (no extra query, no null columns) + child = get_by_id(circuit.id) + assert "generated_derivations" not in child + assert "used_derivations" not in child + + # expand=generated_derivations -> populated on the child; other direction is null + child = get_by_id(circuit.id, {"expand": "generated_derivations"}) + assert child["used_derivations"] is None + assert len(child["generated_derivations"]) == 1 + entry = child["generated_derivations"][0] + assert entry["used"]["id"] == str(root_circuit.id) + assert entry["used"]["type"] == EntityType.circuit + assert entry["derivation_type"] == DerivationType.circuit_extraction + assert entry["label"] == "extracted" + + # expand=used_derivations -> populated on the parent; other direction is null + parent = get_by_id(root_circuit.id, {"expand": "used_derivations"}) + assert parent["generated_derivations"] is None + assert len(parent["used_derivations"]) == 1 + entry = parent["used_derivations"][0] + assert entry["generated"]["id"] == str(circuit.id) + assert entry["generated"]["type"] == EntityType.circuit + assert entry["derivation_type"] == DerivationType.circuit_extraction + assert entry["label"] == "extracted" + + # expand both -> a direction with no derivations is an empty list (not null) + params = {"expand": ["generated_derivations", "used_derivations"]} + child = get_by_id(circuit.id, params) + parent = get_by_id(root_circuit.id, params) + assert len(child["generated_derivations"]) == 1 + assert child["used_derivations"] == [] + assert parent["generated_derivations"] == [] + assert len(parent["used_derivations"]) == 1 + + +def test_filter_and_expand_combined(db, client, root_circuit, circuit, public_circuit, person_id): + """Filtering and expanding compose: filtered rows carry the expanded columns.""" + _add_derivation( + db, + used_id=root_circuit.id, + generated_id=circuit.id, + person_id=person_id, + derivation_type=DerivationType.circuit_extraction, + ) + _add_derivation( + db, + used_id=root_circuit.id, + generated_id=public_circuit.id, + person_id=person_id, + derivation_type=DerivationType.circuit_rewiring, + ) + + data = assert_request( + client.get, + url=ROUTE, + params={ + "generated_derivation__derivation_type": "circuit_extraction", + "expand": "generated_derivations", + }, + ).json()["data"] + assert {d["id"] for d in data} == {str(circuit.id)} + assert data[0]["generated_derivations"][0]["used"]["id"] == str(root_circuit.id) + assert data[0]["used_derivations"] is None From 220104f49a1e506eabef799b9650f81012637bdb Mon Sep 17 00:00:00 2001 From: Pavlo Getta Date: Thu, 25 Jun 2026 09:40:29 +0200 Subject: [PATCH 2/6] Fix list pagination for one-to-many filter joins The paginated id subquery in `_with_subquery` selected ids without DISTINCT, so a one-to-many filter join (e.g. the circuit derivation-type filters, where a circuit can match several Derivation rows) duplicated the row. OFFSET/LIMIT then paged over the duplicated rows while total_items used count(distinct id), so a matching entity could repeat across pages or be skipped. Select DISTINCT ids in the subquery so the limit window operates on distinct entities. This also hardens the pre-existing contribution/mtype/used/generated one-to-many filters. DISTINCT is valid here because every ORDER BY element is already part of the subquery select list. Add a regression test asserting a circuit matched by multiple derivation rows is counted once and does not reappear on a second page. --- app/queries/common.py | 7 ++++++- tests/test_circuit.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/queries/common.py b/app/queries/common.py index bdaaf4f7b..0bfbf29c0 100644 --- a/app/queries/common.py +++ b/app/queries/common.py @@ -253,7 +253,12 @@ def _with_subquery[I: Identifiable]( select_cols = [db_model_class.id] + [ element.label(label_name) for (label_name, element, _) in labeled_sort_columns ] - subq = data_query.with_only_columns(*select_cols).subquery() + # DISTINCT collapses the duplicate id rows that a one-to-many filter join produces + # (e.g. the derivation-type filters, where a circuit can match several Derivation rows). + # Without it, OFFSET/LIMIT would page over the duplicated rows while total_items uses + # count(distinct id), so a circuit could repeat across pages or be skipped. Every ORDER BY + # element is included in the select list above, so SELECT DISTINCT is always valid here. + subq = data_query.with_only_columns(*select_cols).distinct().subquery() outer_order_bys = [] for label_name, _, modifier in labeled_sort_columns: diff --git a/tests/test_circuit.py b/tests/test_circuit.py index 2d45332ba..a2168ddde 100644 --- a/tests/test_circuit.py +++ b/tests/test_circuit.py @@ -564,3 +564,44 @@ def test_filter_and_expand_combined(db, client, root_circuit, circuit, public_ci assert {d["id"] for d in data} == {str(circuit.id)} assert data[0]["generated_derivations"][0]["used"]["id"] == str(root_circuit.id) assert data[0]["used_derivations"] is None + + +def test_derivation_filter_pagination_no_duplicates( + db, client, root_circuit, circuit, public_circuit, person_id +): + """A circuit matching multiple derivation rows must not repeat across pages. + + root_circuit is the `used` source of two extraction derivations, so the one-to-many + filter join yields two rows for it. Pagination must still treat it as a single circuit: + total_items counts distinct circuits and the duplicate join row must not spill it onto a + second page (regression for the derivation-filter pagination bug). + """ + _add_derivation( + db, + used_id=root_circuit.id, + generated_id=circuit.id, + person_id=person_id, + derivation_type=DerivationType.circuit_extraction, + ) + _add_derivation( + db, + used_id=root_circuit.id, + generated_id=public_circuit.id, + person_id=person_id, + derivation_type=DerivationType.circuit_extraction, + ) + + params = {"used_derivation__derivation_type": "circuit_extraction"} + + # only root_circuit is the `used` side of an extraction derivation, despite two join rows + first = assert_request( + client.get, url=ROUTE, params={**params, "page": 1, "page_size": 1} + ).json() + assert first["pagination"]["total_items"] == 1 + assert [d["id"] for d in first["data"]] == [str(root_circuit.id)] + + # the duplicate join row must not place root_circuit on a second page too + second = assert_request( + client.get, url=ROUTE, params={**params, "page": 2, "page_size": 1} + ).json() + assert second["data"] == [] From 66dc9c7f9041abcdd9883d151c5a8d03d46a9401 Mon Sep 17 00:00:00 2001 From: Pavlo Getta Date: Thu, 25 Jun 2026 16:34:05 +0200 Subject: [PATCH 3/6] Generalize derivation filters and expand-read from Circuit to Entity Lift the Derivation-backed filter and expand-read capabilities from Circuit up to the Entity base so every entity subclass inherits them, and add a new entity-id filter alongside derivation_type. Filter (centralized, no per-service edits): - Move NestedDerivationFilter to EntityFilterMixin, extended with used_id / generated_id (+ __in) to filter by the related entity, not only by derivation_type. - factory.py auto-injects the derivation joins and distinct aliases for any Entity model; joins apply only when the filter is set, so default lists pay nothing. Expand-read (centralized core + per-service param): - Move the viewonly derivations relationships and load-aware properties from Circuit to Entity. - GeneratedDerivationRead / UsedDerivationRead + DerivationReadMixin on both EntityRead and EntityReadWoutAssets; always present, null until expanded. - Shared EntityExpand / ExpandDep / apply_derivation_expand applied in router_read_one and router_read_many. - Wire expand into read_one, admin_read_one, read_many, admin_read_many across all entity services; cell_morphology / em_cell_mesh keep their measurement_annotation expand, unified with the derivation expands. No DB migration: the derivation table already exists and the new relationships are viewonly. --- app/db/model.py | 66 ++++++++--------- app/dependencies/common.py | 3 + app/filters/base.py | 12 +++- app/filters/circuit.py | 26 ++----- app/filters/entity.py | 36 +++++++++- app/queries/common.py | 9 +++ app/queries/expand.py | 45 ++++++++++++ app/queries/factory.py | 31 +++++++- app/schemas/circuit.py | 31 +------- app/schemas/entity.py | 32 ++++++++- app/service/analysis_notebook_environment.py | 13 +++- app/service/analysis_notebook_result.py | 13 +++- app/service/analysis_notebook_template.py | 13 +++- app/service/brain_atlas.py | 19 ++++- app/service/brain_atlas_region.py | 13 +++- app/service/cell_composition.py | 13 +++- app/service/cell_morphology.py | 45 +++++++++--- app/service/cell_morphology_protocol.py | 13 +++- app/service/circuit.py | 59 +++++---------- app/service/circuit_extraction_campaign.py | 12 ++++ app/service/circuit_extraction_config.py | 12 ++++ app/service/electrical_cell_recording.py | 12 ++++ app/service/electrical_recording_stimulus.py | 12 ++++ app/service/em_cell_mesh.py | 45 +++++++++--- .../em_dense_reconstruction_dataset.py | 12 ++++ app/service/emodel.py | 12 ++++ app/service/experimental_bouton_density.py | 12 ++++ app/service/experimental_neuron_density.py | 12 ++++ .../experimental_synapses_per_connection.py | 12 ++++ app/service/ion_channel_model.py | 12 ++++ app/service/ion_channel_modeling_campaign.py | 12 ++++ app/service/ion_channel_modeling_config.py | 12 ++++ app/service/ion_channel_recording.py | 12 ++++ app/service/memodel.py | 19 ++++- app/service/memodel_calibration_result.py | 12 ++++ ...mulatable_extracellular_recording_array.py | 12 ++++ app/service/simulation.py | 12 ++++ app/service/simulation_campaign.py | 12 ++++ app/service/simulation_result.py | 12 ++++ app/service/single_neuron_simulation.py | 12 ++++ app/service/single_neuron_synaptome.py | 12 ++++ .../single_neuron_synaptome_simulation.py | 12 ++++ app/service/skeletonization_campaign.py | 12 ++++ app/service/skeletonization_config.py | 12 ++++ app/service/subject.py | 13 +++- app/service/task_config.py | 12 ++++ app/service/task_result.py | 12 ++++ app/service/validation_result.py | 12 ++++ tests/test_brain_atlas.py | 12 ++++ tests/test_cell_morphology_protocol.py | 2 + tests/test_circuit.py | 12 ++-- tests/test_emodel.py | 71 ++++++++++++++++++- tests/test_ion_channel_recording.py | 2 + 53 files changed, 811 insertions(+), 169 deletions(-) create mode 100644 app/queries/expand.py diff --git a/app/db/model.py b/app/db/model.py index cd7b6e221..77aec9164 100644 --- a/app/db/model.py +++ b/app/db/model.py @@ -634,6 +634,39 @@ def __table_args__(cls): # noqa: D105, PLW3201 passive_deletes=False, ) + # View-only relationships to the Derivation rows that reference this entity. + # They are loaded on demand (see the `expand` query param) and read through the + # load-aware properties below, so an un-expanded direction serializes as null instead of + # tripping `raiseload`. Defined on Entity so every entity subclass inherits them. + derivations_as_generated: Mapped[list["Derivation"]] = relationship( + "Derivation", + primaryjoin=lambda: Entity.id == Derivation.generated_id, + foreign_keys=lambda: [Derivation.generated_id], + viewonly=True, + overlaps="generated", + ) + derivations_as_used: Mapped[list["Derivation"]] = relationship( + "Derivation", + primaryjoin=lambda: Entity.id == Derivation.used_id, + foreign_keys=lambda: [Derivation.used_id], + viewonly=True, + overlaps="used", + ) + + @property + def generated_derivations(self) -> list["Derivation"] | None: + """Derivations where this entity is the generated entity, or None if not expanded.""" + if "derivations_as_generated" in sa.inspect(self).unloaded: + return None + return self.derivations_as_generated + + @property + def used_derivations(self) -> list["Derivation"] | None: + """Derivations where this entity is the used entity, or None if not expanded.""" + if "derivations_as_used" in sa.inspect(self).unloaded: + return None + return self.derivations_as_used + __mapper_args__ = { # noqa: RUF012 "polymorphic_identity": __tablename__, "polymorphic_on": "type", @@ -1919,39 +1952,6 @@ class Circuit(ScientificArtifact, NameDescriptionVectorMixin): # calibration_data (multiple entities): ... - # View-only relationships to the Derivation rows that reference this circuit. - # They are loaded on demand (see app/service/circuit.py `expand`) and read through the - # load-aware properties below, so an un-expanded direction serializes as null instead of - # tripping `raiseload`. - derivations_as_generated: Mapped[list["Derivation"]] = relationship( - "Derivation", - primaryjoin=lambda: Circuit.id == Derivation.generated_id, - foreign_keys=lambda: [Derivation.generated_id], - viewonly=True, - overlaps="generated", - ) - derivations_as_used: Mapped[list["Derivation"]] = relationship( - "Derivation", - primaryjoin=lambda: Circuit.id == Derivation.used_id, - foreign_keys=lambda: [Derivation.used_id], - viewonly=True, - overlaps="used", - ) - - @property - def generated_derivations(self) -> list["Derivation"] | None: - """Derivations where this circuit is the generated entity, or None if not expanded.""" - if "derivations_as_generated" in sa.inspect(self).unloaded: - return None - return self.derivations_as_generated - - @property - def used_derivations(self) -> list["Derivation"] | None: - """Derivations where this circuit is the used entity, or None if not expanded.""" - if "derivations_as_used" in sa.inspect(self).unloaded: - return None - return self.derivations_as_used - @declared_attr.directive @classmethod def __table_args__(cls): # noqa: D105, PLW3201 diff --git a/app/dependencies/common.py b/app/dependencies/common.py index 02f87b439..d3e18d128 100644 --- a/app/dependencies/common.py +++ b/app/dependencies/common.py @@ -13,6 +13,7 @@ from app.errors import ApiError, ApiErrorCode from app.filters.base import CustomFilter from app.filters.brain_region import WithinBrainRegionDirection, filter_by_region +from app.queries.expand import EntityExpand from app.queries.filter import filter_from_db from app.queries.types import ApplyOperations from app.schemas.types import Facet, Facets, PaginationRequest @@ -233,3 +234,5 @@ class DerivationQuery(BaseModel): SearchDep = Annotated[Search, Depends()] InBrainRegionDep = Annotated[InBrainRegionQuery, Depends()] DerivationQueryDep = Annotated[DerivationQuery, Depends()] +# `?expand=generated_derivations&expand=used_derivations` — available on every entity read endpoint. +ExpandDep = Annotated[set[EntityExpand] | None, Query()] diff --git a/app/filters/base.py b/app/filters/base.py index 3fc378a66..57697412c 100644 --- a/app/filters/base.py +++ b/app/filters/base.py @@ -240,7 +240,12 @@ def has_nested_filtering_field(self, name: str) -> bool: name: The name of the nested filtering field. It's possible to specify deeply nested filtering fields using the dot notation, e.g. "measurement_kind.pref_label". """ - attr = attrgetter(name)(self) + try: + attr = attrgetter(name)(self) + except AttributeError: + # A join may be registered (e.g. the derivation joins, added for every entity model) + # without this filter declaring the matching field; treat it as not filtering. + return False # ignore nested filters because they are not valid fields return not isinstance(attr, CustomFilter) and attr is not None @@ -251,7 +256,10 @@ def get_nested_filter(self, name: str) -> "CustomFilter[T] | None": name: The name of the nested filter. It's possible to specify deeply nested filters using the dot notation, e.g. "measurement_annotation.measurement_kind". """ - attr = attrgetter(name)(self) + try: + attr = attrgetter(name)(self) + except AttributeError: + return None if isinstance(attr, CustomFilter) and attr.has_filtering_fields(): return attr return None diff --git a/app/filters/circuit.py b/app/filters/circuit.py index 2b28fc176..47843612d 100644 --- a/app/filters/circuit.py +++ b/app/filters/circuit.py @@ -3,24 +3,14 @@ from fastapi_filter import with_prefix -from app.db.model import Circuit, Derivation -from app.db.types import CircuitBuildCategory, CircuitScale, DerivationType, TargetSimulator +from app.db.model import Circuit +from app.db.types import CircuitBuildCategory, CircuitScale, TargetSimulator from app.dependencies.filter import FilterDepends from app.filters.base import CustomFilter from app.filters.common import IdFilterMixin, ILikeSearchFilterMixin, NameFilterMixin from app.filters.scientific_artifact import ScientificArtifactFilter -class NestedDerivationFilter(CustomFilter): - """Filter circuits by derivation type, on either the generated or used side.""" - - derivation_type: DerivationType | None = None - derivation_type__in: list[DerivationType] | None = None - - class Constants(CustomFilter.Constants): - model = Derivation - - class CircuitFilterMixin: scale: CircuitScale | None = None scale__in: list[CircuitScale] | None = None @@ -66,16 +56,8 @@ class CircuitFilter( number_connections__lte: int | None = None number_connections__gte: int | None = None - # derivations where the circuit is the generated (derived) entity: "how it was derived" - generated_derivation: Annotated[ - NestedDerivationFilter | None, - FilterDepends(with_prefix("generated_derivation", NestedDerivationFilter)), - ] = None - # derivations where the circuit is the used (source) entity: "what was derived from it" - used_derivation: Annotated[ - NestedDerivationFilter | None, - FilterDepends(with_prefix("used_derivation", NestedDerivationFilter)), - ] = None + # generated_derivation / used_derivation are inherited from EntityFilterMixin via + # ScientificArtifactFilter, so every entity filter exposes them. order_by: list[str] = ["-creation_date"] # noqa: RUF012 diff --git a/app/filters/entity.py b/app/filters/entity.py index d459a1ce8..6e457591b 100644 --- a/app/filters/entity.py +++ b/app/filters/entity.py @@ -1,15 +1,36 @@ +import uuid from typing import Annotated from fastapi_filter import with_prefix -from app.db.model import Entity -from app.db.types import EntityLifecycleStatus, EntityType +from app.db.model import Derivation, Entity +from app.db.types import DerivationType, EntityLifecycleStatus, EntityType from app.dependencies.filter import FilterDepends from app.filters.base import CustomFilter from app.filters.common import AuthorizedFilterMixin, CreationFilterMixin, IdFilterMixin from app.filters.person import CreatorFilterMixin +class NestedDerivationFilter(CustomFilter): + """Filter entities by a related Derivation, on either the generated or used side. + + Exposed on every entity filter as ``generated_derivation`` / ``used_derivation`` (see + EntityFilterMixin). Besides ``derivation_type``, the related-entity ids are filterable: + on ``generated_derivation`` the meaningful side is ``used_id`` ("derived from entity X"), + on ``used_derivation`` it is ``generated_id`` ("source of entity Y"). + """ + + derivation_type: DerivationType | None = None + derivation_type__in: list[DerivationType] | None = None + used_id: uuid.UUID | None = None + used_id__in: list[uuid.UUID] | None = None + generated_id: uuid.UUID | None = None + generated_id__in: list[uuid.UUID] | None = None + + class Constants(CustomFilter.Constants): + model = Derivation + + class BasicEntityFilter(CustomFilter): type: EntityType | None = None @@ -45,3 +66,14 @@ class EntityFilterMixin( ContributionFilterMixin, ): lifecycle_status: EntityLifecycleStatus | None = None + + # Derivations where this entity is the generated (derived) side: "how it was derived". + generated_derivation: Annotated[ + NestedDerivationFilter | None, + FilterDepends(with_prefix("generated_derivation", NestedDerivationFilter)), + ] = None + # Derivations where this entity is the used (source) side: "what was derived from it". + used_derivation: Annotated[ + NestedDerivationFilter | None, + FilterDepends(with_prefix("used_derivation", NestedDerivationFilter)), + ] = None diff --git a/app/queries/common.py b/app/queries/common.py index 0bfbf29c0..20304aa97 100644 --- a/app/queries/common.py +++ b/app/queries/common.py @@ -1,4 +1,5 @@ import uuid +from collections.abc import Set as AbstractSet from http import HTTPStatus import sqlalchemy as sa @@ -34,6 +35,7 @@ from app.filters.base import Aliases, CustomFilter from app.queries import crud from app.queries.constants import NESTED_RELATIONSHIPS_MAP +from app.queries.expand import apply_derivation_expand from app.queries.filter import filter_from_db from app.queries.types import ApplyOperations, SupportsModelValidate from app.queries.utils import ( @@ -56,6 +58,7 @@ def router_read_one[T: Schema, I: Identifiable]( user_context: UserContext | None, response_schema_class: SupportsModelValidate[T], apply_operations: ApplyOperations[I] | None, + expand: AbstractSet[str] | None = None, ) -> T: """Read a model from the database. @@ -66,6 +69,7 @@ def router_read_one[T: Schema, I: Identifiable]( user_context: the user context with project id and user information. response_schema_class: Pydantic schema class for the returned data. apply_operations: transformer function that modifies the select query. + expand: optional set of derivation directions to eager-load (entity models only). Returns: the model data as a Pydantic model. @@ -81,6 +85,7 @@ def router_read_one[T: Schema, I: Identifiable]( ) if apply_operations: query = apply_operations(query) + query = apply_derivation_expand(query, db_model_class, expand) with ensure_result(error_message=f"{db_model_class.__name__} not found"): row = db.execute(query).unique().scalar_one() return response_schema_class.model_validate(row) @@ -293,6 +298,7 @@ def router_read_many[T: Schema, I: Identifiable]( # noqa: PLR0913 filter_joins: dict[str, ApplyOperations] | None = None, embedding: list[float] | None = None, check_authorized_project: bool = True, + expand: AbstractSet[str] | None = None, ) -> ListResponse[T]: """Read multiple models from the database. @@ -315,6 +321,7 @@ def router_read_many[T: Schema, I: Identifiable]( # noqa: PLR0913 - the keys in `name_to_facet_query_params`, for retrieving the facets. embedding: optional list of floats representing an embedding vector for semantic search. check_authorized_project: Whether to constrain or not to authorized entities + expand: optional set of derivation directions to eager-load (entity models only). Returns: the list of model data, pagination, and facets as a Pydantic model. @@ -369,6 +376,8 @@ def router_read_many[T: Schema, I: Identifiable]( # noqa: PLR0913 data_query = _with_subquery(data_query=data_query, db_model_class=db_model_class) data_query = apply_data_query_operations(data_query) + data_query = apply_derivation_expand(data_query, db_model_class, expand) + # unique is needed b/c it contains results that include joined eager loads against collections data = db.execute(data_query).scalars().unique() diff --git a/app/queries/expand.py b/app/queries/expand.py new file mode 100644 index 000000000..6d35b8d60 --- /dev/null +++ b/app/queries/expand.py @@ -0,0 +1,45 @@ +"""Shared, entity-wide `expand` support for on-demand derivation lists. + +Every entity read schema carries the load-aware ``generated_derivations`` / ``used_derivations`` +fields (see app.schemas.entity.DerivationReadMixin); they serialize as ``null`` unless the matching +direction was eagerly loaded. This module centralizes the enum the read endpoints expose as the +``expand`` query param and the loader options that populate the relationships. +""" + +from collections.abc import Set as AbstractSet +from enum import StrEnum, auto + +import sqlalchemy as sa +from sqlalchemy.orm import selectinload + +from app.db.model import Derivation, Entity, Identifiable + + +class EntityExpand(StrEnum): + """Derivation lists that any entity endpoint can load on demand via ``?expand=``.""" + + generated_derivations = auto() + used_derivations = auto() + + +def apply_derivation_expand( + query: sa.Select, db_model_class: type[Identifiable], expand: AbstractSet[str] | None +) -> sa.Select: + """Eager-load the requested derivation directions onto an entity query. + + A no-op for non-entity models and when nothing is requested. Adding the specific + ``selectinload`` after a service's ``raiseload("*")`` is intentional: the more specific path + overrides the wildcard, so an un-expanded direction stays unloaded and its load-aware property + returns ``None`` instead of tripping ``raiseload``. + """ + if not expand or not issubclass(db_model_class, Entity): + return query + if EntityExpand.generated_derivations in expand: + query = query.options( + selectinload(db_model_class.derivations_as_generated).joinedload(Derivation.used) + ) + if EntityExpand.used_derivations in expand: + query = query.options( + selectinload(db_model_class.derivations_as_used).joinedload(Derivation.generated) + ) + return query diff --git a/app/queries/factory.py b/app/queries/factory.py index bf9894723..3635a11f3 100644 --- a/app/queries/factory.py +++ b/app/queries/factory.py @@ -1,7 +1,7 @@ from typing import Any, cast import sqlalchemy as sa -from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import DeclarativeBase, aliased from app.db.model import ( Agent, @@ -48,6 +48,11 @@ from app.queries.types import ApplyOperations +def _is_entity_model(db_model_class: Any) -> bool: + """Whether the model is an Entity subclass (kept separate to avoid narrowing the caller).""" + return isinstance(db_model_class, type) and issubclass(db_model_class, Entity) + + def query_params_factory[I: Identifiable]( db_model_class: Any, facet_keys: list[str], filter_keys: list[str], aliases: Aliases ) -> tuple[dict[str, FacetQueryParams], dict[str, ApplyOperations[I]]]: @@ -75,6 +80,18 @@ def _get_alias[T: type[DeclarativeBase]](db_cls: T, name: str | None = None) -> value = db_cls if name is None else value.get(name, db_cls) return cast("T", value) + # The generated_derivation / used_derivation filters are inherited by every entity filter + # (see app.filters.entity.EntityFilterMixin). For entity models, register a distinct pair of + # Derivation aliases in `aliases` (mutating the dict the caller also hands to router_read_many) + # so the join lambdas below and `filter_model.filter(...)` resolve the *same* alias objects, + # and so the two directions don't collide on a single un-aliased Derivation table. + is_entity_model = _is_entity_model(db_model_class) + if is_entity_model and Derivation not in aliases: + aliases[Derivation] = { + "generated_derivation": aliased(Derivation, flat=True), + "used_derivation": aliased(Derivation, flat=True), + } + morphology_alias = _get_alias(CellMorphology) cell_morphology_protocol_alias = _get_alias(CellMorphologyProtocol) emodel_alias = _get_alias(EModel) @@ -339,5 +356,15 @@ def _get_alias[T: type[DeclarativeBase]](db_cls: T, name: str | None = None) -> ), } name_to_facet_query_params = {k: name_to_facet_query_params[k] for k in facet_keys} - filter_joins = {k: filter_joins[k] for k in filter_keys} + # Every entity query gets the derivation join lambdas appended (as left joins, so order is + # safe), regardless of whether the service listed them. They are applied only when the + # corresponding nested filter is actually set (see app.queries.filter.filter_from_db). + selected_filter_keys = list(filter_keys) + if is_entity_model: + selected_filter_keys += [ + key + for key in ("generated_derivation", "used_derivation") + if key not in selected_filter_keys + ] + filter_joins = {k: filter_joins[k] for k in selected_filter_keys} return name_to_facet_query_params, filter_joins diff --git a/app/schemas/circuit.py b/app/schemas/circuit.py index 61052a14b..e510f992f 100644 --- a/app/schemas/circuit.py +++ b/app/schemas/circuit.py @@ -1,8 +1,7 @@ import uuid -from app.db.types import CircuitBuildCategory, CircuitScale, DerivationType, TargetSimulator -from app.schemas.base import NameDescriptionMixin, Schema -from app.schemas.entity import NestedEntityRead +from app.db.types import CircuitBuildCategory, CircuitScale, TargetSimulator +from app.schemas.base import NameDescriptionMixin from app.schemas.scientific_artifact import ScientificArtifactCreate, ScientificArtifactRead from app.schemas.utils import make_update_schema @@ -29,32 +28,6 @@ class CircuitRead(CircuitBaseMixin, ScientificArtifactRead): pass -class CircuitGeneratedDerivationRead(Schema): - """A derivation where the circuit is the generated (derived) entity.""" - - used: NestedEntityRead - derivation_type: DerivationType - label: str | None = None - - -class CircuitUsedDerivationRead(Schema): - """A derivation where the circuit is the used (source) entity.""" - - generated: NestedEntityRead - derivation_type: DerivationType - label: str | None = None - - -class CircuitExpandedRead(CircuitRead): - """Circuit read schema with on-demand derivation lists (see `expand` query param). - - A direction that was not expanded serializes as ``null`` (load-aware property on the model). - """ - - generated_derivations: list[CircuitGeneratedDerivationRead] | None = None - used_derivations: list[CircuitUsedDerivationRead] | None = None - - class CircuitCreate(CircuitBaseMixin, ScientificArtifactCreate): pass diff --git a/app/schemas/entity.py b/app/schemas/entity.py index 800ad3ded..0b730717d 100644 --- a/app/schemas/entity.py +++ b/app/schemas/entity.py @@ -4,7 +4,7 @@ from pydantic import RootModel -from app.db.types import EntityLifecycleStatus, EntityType +from app.db.types import DerivationType, EntityLifecycleStatus, EntityType from app.schemas.asset import AssetsMixin from app.schemas.base import AuthorizationOptionalPublicMixin, Schema from app.schemas.identifiable import IdentifiableCreate, IdentifiableRead, NestedIdentifiableRead @@ -35,6 +35,34 @@ class NestedEntityRead(NestedIdentifiableRead, EntityBaseMixin): type: EntityType +class GeneratedDerivationRead(Schema): + """A derivation where this entity is the generated (derived) entity.""" + + used: NestedEntityRead + derivation_type: DerivationType + label: str | None = None + + +class UsedDerivationRead(Schema): + """A derivation where this entity is the used (source) entity.""" + + generated: NestedEntityRead + derivation_type: DerivationType + label: str | None = None + + +class DerivationReadMixin: + """On-demand derivation lists, available on every entity read (see the `expand` query param). + + A direction that was not expanded serializes as ``null`` (load-aware property on the Entity + model, so no extra query and `raiseload` is never tripped); an expanded-but-empty direction + serializes as ``[]``. + """ + + generated_derivations: list[GeneratedDerivationRead] | None = None + used_derivations: list[UsedDerivationRead] | None = None + + from app.schemas.contribution import ContributionReadWithoutEntityMixin # noqa: E402 @@ -42,6 +70,7 @@ class EntityReadWoutAssets( IdentifiableRead, EntityBaseMixin, ContributionReadWithoutEntityMixin, + DerivationReadMixin, ): """Entity model that includes created_by and updated_by information.""" @@ -53,6 +82,7 @@ class EntityRead( EntityBaseMixin, AssetsMixin, ContributionReadWithoutEntityMixin, + DerivationReadMixin, ): """Entity model that includes created_by and updated_by information.""" diff --git a/app/service/analysis_notebook_environment.py b/app/service/analysis_notebook_environment.py index 8b8ad047e..5c82589e4 100644 --- a/app/service/analysis_notebook_environment.py +++ b/app/service/analysis_notebook_environment.py @@ -11,7 +11,7 @@ Person, ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep -from app.dependencies.common import FacetsDep, PaginationQuery, SearchDep +from app.dependencies.common import ExpandDep, FacetsDep, PaginationQuery, SearchDep from app.dependencies.db import SessionDep from app.filters.analysis_notebook_environment import AnalysisNotebookEnvironmentFilterDep from app.queries.common import ( @@ -21,6 +21,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.analysis_notebook_environment import ( AnalysisNotebookEnvironmentAdminUpdate, @@ -52,6 +53,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> AnalysisNotebookEnvironmentRead: return router_read_one( db=db, @@ -60,12 +62,14 @@ def read_one( user_context=user_context, response_schema_class=AnalysisNotebookEnvironmentRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> AnalysisNotebookEnvironmentRead: return router_read_one( db=db, @@ -74,6 +78,7 @@ def admin_read_one( user_context=None, response_schema_class=AnalysisNotebookEnvironmentRead, apply_operations=_load, + expand=expand, ) @@ -136,6 +141,7 @@ def _read_many( filter_model: AnalysisNotebookEnvironmentFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[AnalysisNotebookEnvironmentRead]: agent_alias = aliased(Agent, flat=True) @@ -180,6 +186,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -190,6 +197,7 @@ def read_many( filter_model: AnalysisNotebookEnvironmentFilterDep, with_search: SearchDep, with_facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[AnalysisNotebookEnvironmentRead]: return _read_many( user_context=user_context, @@ -198,6 +206,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=with_facets, + expand=expand, check_authorized_project=True, ) @@ -209,6 +218,7 @@ def admin_read_many( filter_model: AnalysisNotebookEnvironmentFilterDep, with_search: SearchDep, with_facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[AnalysisNotebookEnvironmentRead]: return _read_many( user_context=user_context, @@ -217,6 +227,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=with_facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/analysis_notebook_result.py b/app/service/analysis_notebook_result.py index ad1812677..4bbaf7f82 100644 --- a/app/service/analysis_notebook_result.py +++ b/app/service/analysis_notebook_result.py @@ -11,7 +11,7 @@ Person, ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep -from app.dependencies.common import FacetsDep, PaginationQuery, SearchDep +from app.dependencies.common import ExpandDep, FacetsDep, PaginationQuery, SearchDep from app.dependencies.db import SessionDep from app.filters.analysis_notebook_result import AnalysisNotebookResultFilterDep from app.queries.common import ( @@ -21,6 +21,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.analysis_notebook_result import ( AnalysisNotebookResultAdminUpdate, @@ -52,6 +53,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> AnalysisNotebookResultRead: return router_read_one( db=db, @@ -60,12 +62,14 @@ def read_one( user_context=user_context, response_schema_class=AnalysisNotebookResultRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> AnalysisNotebookResultRead: return router_read_one( db=db, @@ -74,6 +78,7 @@ def admin_read_one( user_context=None, response_schema_class=AnalysisNotebookResultRead, apply_operations=_load, + expand=expand, ) @@ -136,6 +141,7 @@ def _read_many( filter_model: AnalysisNotebookResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[AnalysisNotebookResultRead]: agent_alias = aliased(Agent, flat=True) @@ -178,6 +184,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -188,6 +195,7 @@ def read_many( filter_model: AnalysisNotebookResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[AnalysisNotebookResultRead]: return _read_many( user_context=user_context, @@ -196,6 +204,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -207,6 +216,7 @@ def admin_read_many( filter_model: AnalysisNotebookResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[AnalysisNotebookResultRead]: return _read_many( user_context=user_context, @@ -215,6 +225,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/analysis_notebook_template.py b/app/service/analysis_notebook_template.py index 5fcbdb359..52522369c 100644 --- a/app/service/analysis_notebook_template.py +++ b/app/service/analysis_notebook_template.py @@ -11,7 +11,7 @@ Person, ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep -from app.dependencies.common import FacetsDep, PaginationQuery, SearchDep +from app.dependencies.common import ExpandDep, FacetsDep, PaginationQuery, SearchDep from app.dependencies.db import SessionDep from app.filters.analysis_notebook_template import AnalysisNotebookTemplateFilterDep from app.queries.common import ( @@ -21,6 +21,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.analysis_notebook_template import ( AnalysisNotebookTemplateAdminUpdate, @@ -50,6 +51,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> AnalysisNotebookTemplateRead: return router_read_one( db=db, @@ -58,12 +60,14 @@ def read_one( user_context=user_context, response_schema_class=AnalysisNotebookTemplateRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> AnalysisNotebookTemplateRead: return router_read_one( db=db, @@ -72,6 +76,7 @@ def admin_read_one( user_context=None, response_schema_class=AnalysisNotebookTemplateRead, apply_operations=_load, + expand=expand, ) @@ -134,6 +139,7 @@ def _read_many( filter_model: AnalysisNotebookTemplateFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[AnalysisNotebookTemplateRead]: agent_alias = aliased(Agent, flat=True) @@ -176,6 +182,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -186,6 +193,7 @@ def read_many( filter_model: AnalysisNotebookTemplateFilterDep, with_search: SearchDep, with_facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[AnalysisNotebookTemplateRead]: return _read_many( user_context=user_context, @@ -194,6 +202,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=with_facets, + expand=expand, check_authorized_project=True, ) @@ -205,6 +214,7 @@ def admin_read_many( filter_model: AnalysisNotebookTemplateFilterDep, with_search: SearchDep, with_facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[AnalysisNotebookTemplateRead]: return _read_many( user_context=user_context, @@ -213,6 +223,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=with_facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/brain_atlas.py b/app/service/brain_atlas.py index f5705de92..85d2ad6d2 100644 --- a/app/service/brain_atlas.py +++ b/app/service/brain_atlas.py @@ -8,10 +8,12 @@ from app.db.model import BrainAtlas, BrainAtlasRegion, Contribution from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, PaginationQuery, ) from app.dependencies.db import SessionDep from app.filters.brain_atlas import BrainAtlasFilterDep, BrainAtlasRegionFilterDep +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.brain_atlas import ( BrainAtlasAdminUpdate, @@ -48,6 +50,7 @@ def _read_many( db: SessionDep, pagination_request: PaginationQuery, filter_model: BrainAtlasFilterDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[BrainAtlasRead]: aliases: Aliases = {} @@ -79,6 +82,7 @@ def _read_many( filter_model=filter_model, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -87,12 +91,14 @@ def read_many( db: SessionDep, pagination_request: PaginationQuery, filter_model: BrainAtlasFilterDep, + expand: ExpandDep = None, ) -> ListResponse[BrainAtlasRead]: return _read_many( user_context=user_context, db=db, pagination_request=pagination_request, filter_model=filter_model, + expand=expand, check_authorized_project=True, ) @@ -102,17 +108,24 @@ def admin_read_many( db: SessionDep, pagination_request: PaginationQuery, filter_model: BrainAtlasFilterDep, + expand: ExpandDep = None, ) -> ListResponse[BrainAtlasRead]: return _read_many( db=db, user_context=user_context, pagination_request=pagination_request, filter_model=filter_model, + expand=expand, check_authorized_project=False, ) -def read_one(user_context: UserContextDep, id_: uuid.UUID, db: SessionDep) -> BrainAtlasRead: +def read_one( + user_context: UserContextDep, + id_: uuid.UUID, + db: SessionDep, + expand: ExpandDep = None, +) -> BrainAtlasRead: return app.queries.common.router_read_one( id_=id_, db=db, @@ -120,10 +133,11 @@ def read_one(user_context: UserContextDep, id_: uuid.UUID, db: SessionDep) -> Br user_context=user_context, response_schema_class=BrainAtlasRead, apply_operations=_load_brain_atlas, + expand=expand, ) -def admin_read_one(db: SessionDep, id_: uuid.UUID) -> BrainAtlasRead: +def admin_read_one(db: SessionDep, id_: uuid.UUID, expand: ExpandDep = None) -> BrainAtlasRead: return app.queries.common.router_read_one( id_=id_, db=db, @@ -131,6 +145,7 @@ def admin_read_one(db: SessionDep, id_: uuid.UUID) -> BrainAtlasRead: user_context=None, response_schema_class=BrainAtlasRead, apply_operations=_load_brain_atlas, + expand=expand, ) diff --git a/app/service/brain_atlas_region.py b/app/service/brain_atlas_region.py index ca1780995..82b8fc606 100644 --- a/app/service/brain_atlas_region.py +++ b/app/service/brain_atlas_region.py @@ -6,7 +6,7 @@ from app.db.model import Agent, BrainAtlasRegion as Model, Contribution, Person from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep -from app.dependencies.common import FacetsDep, PaginationQuery, SearchDep +from app.dependencies.common import ExpandDep, FacetsDep, PaginationQuery, SearchDep from app.dependencies.db import SessionDep from app.filters.brain_atlas import BrainAtlasRegionFilterDep from app.queries.common import ( @@ -16,6 +16,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.brain_atlas_region import ( BrainAtlasRegionAdminUpdate, @@ -47,6 +48,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> BrainAtlasRegionRead: return router_read_one( db=db, @@ -55,12 +57,14 @@ def read_one( user_context=user_context, response_schema_class=BrainAtlasRegionRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> BrainAtlasRegionRead: return router_read_one( db=db, @@ -69,6 +73,7 @@ def admin_read_one( user_context=None, response_schema_class=BrainAtlasRegionRead, apply_operations=_load, + expand=expand, ) @@ -131,6 +136,7 @@ def _read_many( filter_model: BrainAtlasRegionFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[BrainAtlasRegionRead]: agent_alias = aliased(Agent, flat=True) @@ -175,6 +181,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -185,6 +192,7 @@ def read_many( filter_model: BrainAtlasRegionFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[BrainAtlasRegionRead]: return _read_many( user_context=user_context, @@ -193,6 +201,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -204,6 +213,7 @@ def admin_read_many( filter_model: BrainAtlasRegionFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[BrainAtlasRegionRead]: return _read_many( user_context=user_context, @@ -212,6 +222,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/cell_composition.py b/app/service/cell_composition.py index 350f1de73..fe5a558ce 100644 --- a/app/service/cell_composition.py +++ b/app/service/cell_composition.py @@ -10,7 +10,7 @@ Person, ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep -from app.dependencies.common import PaginationQuery, SearchDep +from app.dependencies.common import ExpandDep, PaginationQuery, SearchDep from app.dependencies.db import SessionDep from app.filters.cell_composition import CellCompositionFilterDep from app.queries.common import ( @@ -20,6 +20,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.cell_composition import ( CellCompositionAdminUpdate, @@ -116,6 +117,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> CellCompositionRead: return router_read_one( db=db, @@ -124,12 +126,14 @@ def read_one( user_context=user_context, response_schema_class=CellCompositionRead, apply_operations=_load_from_db, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> CellCompositionRead: return router_read_one( db=db, @@ -138,6 +142,7 @@ def admin_read_one( user_context=None, response_schema_class=CellCompositionRead, apply_operations=_load_from_db, + expand=expand, ) @@ -148,6 +153,7 @@ def _read_many( pagination_request: PaginationQuery, filter_model: CellCompositionFilterDep, with_search: SearchDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[CellCompositionRead]: aliases: Aliases = { @@ -183,6 +189,7 @@ def _read_many( filter_joins=filter_joins, name_to_facet_query_params=name_to_facet_query_params, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -192,6 +199,7 @@ def read_many( pagination_request: PaginationQuery, filter_model: CellCompositionFilterDep, with_search: SearchDep, + expand: ExpandDep = None, ) -> ListResponse[CellCompositionRead]: return _read_many( user_context=user_context, @@ -199,6 +207,7 @@ def read_many( pagination_request=pagination_request, filter_model=filter_model, with_search=with_search, + expand=expand, check_authorized_project=True, ) @@ -209,6 +218,7 @@ def admin_read_many( pagination_request: PaginationQuery, filter_model: CellCompositionFilterDep, with_search: SearchDep, + expand: ExpandDep = None, ) -> ListResponse[CellCompositionRead]: return _read_many( user_context=user_context, @@ -216,5 +226,6 @@ def admin_read_many( pagination_request=pagination_request, filter_model=filter_model, with_search=with_search, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/cell_morphology.py b/app/service/cell_morphology.py index f990b2231..5e321f936 100644 --- a/app/service/cell_morphology.py +++ b/app/service/cell_morphology.py @@ -55,6 +55,9 @@ class ExpandableAttribute(StrEnum): measurement_annotation = auto() + # Inherited entity-wide derivation expands; loaded centrally (see apply_derivation_expand). + generated_derivations = auto() + used_derivations = auto() def _load_from_db(query: sa.Select, *, expand: set[ExpandableAttribute] | None = None) -> sa.Select: @@ -98,7 +101,11 @@ def read_one( id_: uuid.UUID, expand: Annotated[set[ExpandableAttribute] | None, Query()] = None, ) -> CellMorphologyRead | CellMorphologyAnnotationExpandedRead: - response_schema_class = CellMorphologyAnnotationExpandedRead if expand else CellMorphologyRead + response_schema_class = ( + CellMorphologyAnnotationExpandedRead + if expand and ExpandableAttribute.measurement_annotation in expand + else CellMorphologyRead + ) apply_operations = partial(_load_from_db, expand=expand) return router_read_one( id_=id_, @@ -107,20 +114,29 @@ def read_one( user_context=user_context, response_schema_class=response_schema_class, apply_operations=apply_operations, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, -) -> CellMorphologyRead: + expand: Annotated[set[ExpandableAttribute] | None, Query()] = None, +) -> CellMorphologyRead | CellMorphologyAnnotationExpandedRead: + response_schema_class = ( + CellMorphologyAnnotationExpandedRead + if expand and ExpandableAttribute.measurement_annotation in expand + else CellMorphologyRead + ) + apply_operations = partial(_load_from_db, expand=expand) return router_read_one( id_=id_, db=db, db_model_class=CellMorphology, user_context=None, - response_schema_class=CellMorphologyRead, - apply_operations=_load_from_db, + response_schema_class=response_schema_class, + apply_operations=apply_operations, + expand=expand, ) @@ -184,8 +200,9 @@ def _read_many( with_search: SearchDep, with_facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: set[ExpandableAttribute] | None, check_authorized_project: bool, -) -> ListResponse[CellMorphologyRead]: +) -> ListResponse[CellMorphologyRead | CellMorphologyAnnotationExpandedRead]: subject_alias = aliased(Subject, flat=True) agent_alias = aliased(Agent, flat=True) created_by_alias = aliased(Person, flat=True) @@ -226,6 +243,11 @@ def _read_many( filter_keys=filter_keys, aliases=aliases, ) + response_schema_class = ( + CellMorphologyAnnotationExpandedRead + if expand and ExpandableAttribute.measurement_annotation in expand + else CellMorphologyRead + ) return router_read_many( db=db, db_model_class=CellMorphology, @@ -235,13 +257,14 @@ def _read_many( facets=with_facets, aliases=aliases, apply_filter_query_operations=None, - apply_data_query_operations=_load_from_db, + apply_data_query_operations=partial(_load_from_db, expand=expand), pagination_request=pagination_request, - response_schema_class=CellMorphologyRead, + response_schema_class=response_schema_class, name_to_facet_query_params=name_to_facet_query_params, filter_model=filter_model, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -253,7 +276,8 @@ def read_many( with_search: SearchDep, with_facets: FacetsDep, in_brain_region: InBrainRegionDep, -) -> ListResponse[CellMorphologyRead]: + expand: Annotated[set[ExpandableAttribute] | None, Query()] = None, +) -> ListResponse[CellMorphologyRead | CellMorphologyAnnotationExpandedRead]: return _read_many( user_context=user_context, db=db, @@ -262,6 +286,7 @@ def read_many( with_search=with_search, with_facets=with_facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -274,7 +299,8 @@ def admin_read_many( with_search: SearchDep, with_facets: FacetsDep, in_brain_region: InBrainRegionDep, -) -> ListResponse[CellMorphologyRead]: + expand: Annotated[set[ExpandableAttribute] | None, Query()] = None, +) -> ListResponse[CellMorphologyRead | CellMorphologyAnnotationExpandedRead]: return _read_many( user_context=user_context, db=db, @@ -283,6 +309,7 @@ def admin_read_many( with_search=with_search, with_facets=with_facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/cell_morphology_protocol.py b/app/service/cell_morphology_protocol.py index 4fcf5592d..f315fa8d6 100644 --- a/app/service/cell_morphology_protocol.py +++ b/app/service/cell_morphology_protocol.py @@ -13,7 +13,7 @@ ) from app.db.utils import CELL_MORPHOLOGY_GENERATION_TYPE_TO_CLASS from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep -from app.dependencies.common import FacetsDep, PaginationQuery +from app.dependencies.common import ExpandDep, FacetsDep, PaginationQuery from app.dependencies.db import SessionDep from app.errors import ensure_valid_schema from app.filters.cell_morphology_protocol import CellMorphologyProtocolFilterDep @@ -24,6 +24,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.cell_morphology_protocol import ( CellMorphologyProtocolCreate, @@ -55,6 +56,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> CellMorphologyProtocolRead: return router_read_one( id_=id_, @@ -63,12 +65,14 @@ def read_one( user_context=user_context, response_schema_class=CellMorphologyProtocolReadAdapter, apply_operations=_load_from_db, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> CellMorphologyProtocolRead: return router_read_one( db=db, @@ -77,6 +81,7 @@ def admin_read_one( user_context=None, response_schema_class=CellMorphologyProtocolReadAdapter, apply_operations=_load_from_db, + expand=expand, ) @@ -103,6 +108,7 @@ def _read_many( pagination_request: PaginationQuery, filter_model: CellMorphologyProtocolFilterDep, with_facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[CellMorphologyProtocolRead]: agent_alias = aliased(Agent, flat=True) @@ -147,6 +153,7 @@ def _read_many( filter_model=filter_model, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -156,6 +163,7 @@ def read_many( pagination_request: PaginationQuery, filter_model: CellMorphologyProtocolFilterDep, with_facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[CellMorphologyProtocolRead]: return _read_many( user_context=user_context, @@ -163,6 +171,7 @@ def read_many( pagination_request=pagination_request, filter_model=filter_model, with_facets=with_facets, + expand=expand, check_authorized_project=True, ) @@ -173,6 +182,7 @@ def admin_read_many( pagination_request: PaginationQuery, filter_model: CellMorphologyProtocolFilterDep, with_facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[CellMorphologyProtocolRead]: return _read_many( user_context=user_context, @@ -180,6 +190,7 @@ def admin_read_many( pagination_request=pagination_request, filter_model=filter_model, with_facets=with_facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/circuit.py b/app/service/circuit.py index 1fb7f9f27..e358d924d 100644 --- a/app/service/circuit.py +++ b/app/service/circuit.py @@ -1,22 +1,19 @@ import uuid -from enum import StrEnum, auto -from functools import partial -from typing import TYPE_CHECKING, Annotated +from typing import TYPE_CHECKING import sqlalchemy as sa -from fastapi import Query from sqlalchemy.orm import aliased, joinedload, raiseload, selectinload from app.db.model import ( Agent, Circuit, Contribution, - Derivation, Person, Subject, ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -31,11 +28,11 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.circuit import ( CircuitAdminUpdate, CircuitCreate, - CircuitExpandedRead, CircuitRead, CircuitUserUpdate, ) @@ -46,15 +43,8 @@ from app.filters.base import Aliases -class ExpandableAttribute(StrEnum): - """Derivation lists that can be loaded on demand via the `expand` query param.""" - - generated_derivations = auto() - used_derivations = auto() - - -def _load(query: sa.Select, *, expand: set[ExpandableAttribute] | None = None): - query = query.options( +def _load(query: sa.Select): + return query.options( joinedload(Circuit.license), joinedload(Circuit.subject).options( joinedload(Subject.species), @@ -70,21 +60,13 @@ def _load(query: sa.Select, *, expand: set[ExpandableAttribute] | None = None): selectinload(Circuit.assets), raiseload("*"), ) - if expand and ExpandableAttribute.generated_derivations in expand: - query = query.options( - selectinload(Circuit.derivations_as_generated).joinedload(Derivation.used) - ) - if expand and ExpandableAttribute.used_derivations in expand: - query = query.options( - selectinload(Circuit.derivations_as_used).joinedload(Derivation.generated) - ) - return query def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> CircuitRead: return router_read_one( db=db, @@ -93,12 +75,14 @@ def read_one( user_context=user_context, response_schema_class=CircuitRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> CircuitRead: return router_read_one( db=db, @@ -107,6 +91,7 @@ def admin_read_one( user_context=None, response_schema_class=CircuitRead, apply_operations=_load, + expand=expand, ) @@ -170,15 +155,13 @@ def _read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, - expand: set[ExpandableAttribute] | None, + expand: set[EntityExpand] | None, check_authorized_project: bool, -) -> ListResponse[CircuitRead | CircuitExpandedRead]: +) -> ListResponse[CircuitRead]: subject_alias = aliased(Subject, flat=True) agent_alias = aliased(Agent, flat=True) created_by_alias = aliased(Person, flat=True) updated_by_alias = aliased(Person, flat=True) - generated_derivation_alias = aliased(Derivation, flat=True) - used_derivation_alias = aliased(Derivation, flat=True) aliases: Aliases = { Subject: subject_alias, @@ -189,10 +172,6 @@ def _read_many( "created_by": created_by_alias, "updated_by": updated_by_alias, }, - Derivation: { - "generated_derivation": generated_derivation_alias, - "used_derivation": used_derivation_alias, - }, } facet_keys = [ "brain_region", @@ -205,8 +184,6 @@ def _read_many( filter_keys = [ "subject", *facet_keys, - "generated_derivation", - "used_derivation", ] name_to_facet_query_params, filter_joins = query_params_factory( db_model_class=Circuit, @@ -214,7 +191,6 @@ def _read_many( filter_keys=filter_keys, aliases=aliases, ) - response_schema_class = CircuitExpandedRead if expand else CircuitRead return router_read_many( db=db, filter_model=filter_model, @@ -224,13 +200,14 @@ def _read_many( facets=facets, name_to_facet_query_params=name_to_facet_query_params, apply_filter_query_operations=None, - apply_data_query_operations=partial(_load, expand=expand), + apply_data_query_operations=_load, aliases=aliases, pagination_request=pagination_request, - response_schema_class=response_schema_class, + response_schema_class=CircuitRead, authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -242,8 +219,8 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, - expand: Annotated[set[ExpandableAttribute] | None, Query()] = None, -) -> ListResponse[CircuitRead | CircuitExpandedRead]: + expand: ExpandDep = None, +) -> ListResponse[CircuitRead]: return _read_many( user_context=user_context, db=db, @@ -265,8 +242,8 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, - expand: Annotated[set[ExpandableAttribute] | None, Query()] = None, -) -> ListResponse[CircuitRead | CircuitExpandedRead]: + expand: ExpandDep = None, +) -> ListResponse[CircuitRead]: return _read_many( user_context=user_context, db=db, diff --git a/app/service/circuit_extraction_campaign.py b/app/service/circuit_extraction_campaign.py index bd41c420a..9a632b6ed 100644 --- a/app/service/circuit_extraction_campaign.py +++ b/app/service/circuit_extraction_campaign.py @@ -11,6 +11,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -24,6 +25,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.circuit_extraction_campaign import ( CircuitExtractionCampaignAdminUpdate, @@ -52,6 +54,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> CircuitExtractionCampaignRead: return router_read_one( db=db, @@ -60,12 +63,14 @@ def read_one( user_context=user_context, response_schema_class=CircuitExtractionCampaignRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> CircuitExtractionCampaignRead: return router_read_one( db=db, @@ -74,6 +79,7 @@ def admin_read_one( user_context=None, response_schema_class=CircuitExtractionCampaignRead, apply_operations=_load, + expand=expand, ) @@ -136,6 +142,7 @@ def _read_many( filter_model: CircuitExtractionCampaignFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[CircuitExtractionCampaignRead]: agent_alias = aliased(Agent, flat=True) @@ -177,6 +184,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -187,6 +195,7 @@ def read_many( filter_model: CircuitExtractionCampaignFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[CircuitExtractionCampaignRead]: return _read_many( user_context=user_context, @@ -195,6 +204,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -206,6 +216,7 @@ def admin_read_many( filter_model: CircuitExtractionCampaignFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[CircuitExtractionCampaignRead]: return _read_many( user_context=user_context, @@ -214,6 +225,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/circuit_extraction_config.py b/app/service/circuit_extraction_config.py index 438394412..25bdfe018 100644 --- a/app/service/circuit_extraction_config.py +++ b/app/service/circuit_extraction_config.py @@ -12,6 +12,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -25,6 +26,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.circuit_extraction_config import ( CircuitExtractionConfigAdminUpdate, @@ -54,6 +56,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> CircuitExtractionConfigRead: return router_read_one( db=db, @@ -62,12 +65,14 @@ def read_one( user_context=user_context, response_schema_class=CircuitExtractionConfigRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> CircuitExtractionConfigRead: return router_read_one( db=db, @@ -76,6 +81,7 @@ def admin_read_one( user_context=None, response_schema_class=CircuitExtractionConfigRead, apply_operations=_load, + expand=expand, ) @@ -138,6 +144,7 @@ def _read_many( filter_model: CircuitExtractionConfigFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[CircuitExtractionConfigRead]: agent_alias = aliased(Agent, flat=True) @@ -183,6 +190,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -193,6 +201,7 @@ def read_many( filter_model: CircuitExtractionConfigFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[CircuitExtractionConfigRead]: return _read_many( user_context=user_context, @@ -201,6 +210,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -212,6 +222,7 @@ def admin_read_many( filter_model: CircuitExtractionConfigFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[CircuitExtractionConfigRead]: return _read_many( user_context=user_context, @@ -220,6 +231,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/electrical_cell_recording.py b/app/service/electrical_cell_recording.py index 48f99c2bc..24ae4a52f 100644 --- a/app/service/electrical_cell_recording.py +++ b/app/service/electrical_cell_recording.py @@ -13,6 +13,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -27,6 +28,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.electrical_cell_recording import ( ElectricalCellRecordingAdminUpdate, @@ -66,6 +68,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ElectricalCellRecordingRead: return router_read_one( db=db, @@ -74,12 +77,14 @@ def read_one( user_context=user_context, response_schema_class=ElectricalCellRecordingRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ElectricalCellRecordingRead: return router_read_one( db=db, @@ -88,6 +93,7 @@ def admin_read_one( user_context=None, response_schema_class=ElectricalCellRecordingRead, apply_operations=_load, + expand=expand, ) @@ -115,6 +121,7 @@ def _read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[ElectricalCellRecordingRead]: agent_alias = aliased(Agent, flat=True) @@ -173,6 +180,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -184,6 +192,7 @@ def read_many( with_search: SearchDep, with_facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[ElectricalCellRecordingRead]: return _read_many( user_context=user_context, @@ -193,6 +202,7 @@ def read_many( with_search=with_search, facets=with_facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -205,6 +215,7 @@ def admin_read_many( with_search: SearchDep, with_facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[ElectricalCellRecordingRead]: return _read_many( user_context=user_context, @@ -214,6 +225,7 @@ def admin_read_many( with_search=with_search, facets=with_facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/electrical_recording_stimulus.py b/app/service/electrical_recording_stimulus.py index 7f2bef503..abb176204 100644 --- a/app/service/electrical_recording_stimulus.py +++ b/app/service/electrical_recording_stimulus.py @@ -12,6 +12,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -25,6 +26,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.electrical_recording_stimulus import ( ElectricalRecordingStimulusAdminUpdate, @@ -55,6 +57,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ElectricalRecordingStimulusRead: return router_read_one( db=db, @@ -63,12 +66,14 @@ def read_one( user_context=user_context, response_schema_class=ElectricalRecordingStimulusRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ElectricalRecordingStimulusRead: return router_read_one( db=db, @@ -77,6 +82,7 @@ def admin_read_one( user_context=None, response_schema_class=ElectricalRecordingStimulusRead, apply_operations=_load, + expand=expand, ) @@ -121,6 +127,7 @@ def _read_many( filter_model: ElectricalRecordingStimulusFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[ElectricalRecordingStimulusRead]: agent_alias = aliased(Agent, flat=True) @@ -168,6 +175,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -178,6 +186,7 @@ def read_many( filter_model: ElectricalRecordingStimulusFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[ElectricalRecordingStimulusRead]: return _read_many( user_context=user_context, @@ -186,6 +195,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -197,6 +207,7 @@ def admin_read_many( filter_model: ElectricalRecordingStimulusFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[ElectricalRecordingStimulusRead]: return _read_many( user_context=user_context, @@ -205,6 +216,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/em_cell_mesh.py b/app/service/em_cell_mesh.py index a1b52e18d..b1666fadd 100644 --- a/app/service/em_cell_mesh.py +++ b/app/service/em_cell_mesh.py @@ -47,6 +47,9 @@ class Expandable(StrEnum): measurement_annotation = auto() + # Inherited entity-wide derivation expands; loaded centrally (see apply_derivation_expand). + generated_derivations = auto() + used_derivations = auto() def _load(q: Select[EMCellMesh], *, expand: set[Expandable] | None = None) -> Select[EMCellMesh]: @@ -92,8 +95,9 @@ def _read_many( filter_model: EMCellMeshFilterDep, in_brain_region: InBrainRegionDep, facets: FacetsDep, + expand: set[Expandable] | None, check_authorized_project: bool, -) -> ListResponse[EMCellMeshRead]: +) -> ListResponse[EMCellMeshRead | EMCellMeshAnnotationExpandedRead]: subject_alias = aliased(Subject, flat=True) em_dense_reconstruction_dataset_alias = aliased(EMDenseReconstructionDataset, flat=True) aliases: Aliases = { @@ -128,6 +132,11 @@ def _read_many( filter_keys=filter_keys, aliases=aliases, ) + response_schema_class = ( + EMCellMeshAnnotationExpandedRead + if expand and Expandable.measurement_annotation in expand + else EMCellMeshRead + ) return router_read_many( db=db, db_model_class=EMCellMesh, @@ -136,14 +145,15 @@ def _read_many( with_in_brain_region=in_brain_region, facets=facets, aliases=aliases, - apply_data_query_operations=_load, + apply_data_query_operations=partial(_load, expand=expand), apply_filter_query_operations=None, pagination_request=pagination_request, - response_schema_class=EMCellMeshRead, + response_schema_class=response_schema_class, name_to_facet_query_params=name_to_facet_query_params, filter_model=filter_model, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -155,7 +165,8 @@ def read_many( with_search: SearchDep, with_facets: FacetsDep, in_brain_region: InBrainRegionDep, -) -> ListResponse[EMCellMeshRead]: + expand: Annotated[set[Expandable] | None, Query()] = None, +) -> ListResponse[EMCellMeshRead | EMCellMeshAnnotationExpandedRead]: return _read_many( user_context=user_context, db=db, @@ -164,6 +175,7 @@ def read_many( with_search=with_search, facets=with_facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -176,7 +188,8 @@ def admin_read_many( with_search: SearchDep, with_facets: FacetsDep, in_brain_region: InBrainRegionDep, -) -> ListResponse[EMCellMeshRead]: + expand: Annotated[set[Expandable] | None, Query()] = None, +) -> ListResponse[EMCellMeshRead | EMCellMeshAnnotationExpandedRead]: return _read_many( user_context=user_context, db=db, @@ -185,6 +198,7 @@ def admin_read_many( with_search=with_search, facets=with_facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) @@ -195,7 +209,11 @@ def read_one( id_: uuid.UUID, expand: Annotated[set[Expandable] | None, Query()] = None, ) -> EMCellMeshRead | EMCellMeshAnnotationExpandedRead: - response_schema_class = EMCellMeshAnnotationExpandedRead if expand else EMCellMeshRead + response_schema_class = ( + EMCellMeshAnnotationExpandedRead + if expand and Expandable.measurement_annotation in expand + else EMCellMeshRead + ) apply_operations = partial(_load, expand=expand) return router_read_one( id_=id_, @@ -204,20 +222,29 @@ def read_one( user_context=user_context, response_schema_class=response_schema_class, apply_operations=apply_operations, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, -) -> EMCellMeshRead: + expand: Annotated[set[Expandable] | None, Query()] = None, +) -> EMCellMeshRead | EMCellMeshAnnotationExpandedRead: + response_schema_class = ( + EMCellMeshAnnotationExpandedRead + if expand and Expandable.measurement_annotation in expand + else EMCellMeshRead + ) + apply_operations = partial(_load, expand=expand) return router_read_one( id_=id_, db=db, db_model_class=EMCellMesh, user_context=None, - response_schema_class=EMCellMeshRead, - apply_operations=_load, + response_schema_class=response_schema_class, + apply_operations=apply_operations, + expand=expand, ) diff --git a/app/service/em_dense_reconstruction_dataset.py b/app/service/em_dense_reconstruction_dataset.py index d6b6099ad..098f4f6c5 100644 --- a/app/service/em_dense_reconstruction_dataset.py +++ b/app/service/em_dense_reconstruction_dataset.py @@ -6,6 +6,7 @@ from app.db.model import Contribution, EMDenseReconstructionDataset, Person, Subject from app.dependencies.auth import AdminContextDep, AdminContextWithProjectIdDep, UserContextDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -20,6 +21,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.em_dense_reconstruction_dataset import ( EMDenseReconstructionDatasetAdminUpdate, @@ -58,6 +60,7 @@ def _read_many( filter_model: EMDenseReconstructionDatasetFilterDep, in_brain_region: InBrainRegionDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[EMDenseReconstructionDatasetRead]: subject_alias = aliased(Subject, flat=True) @@ -103,6 +106,7 @@ def _read_many( filter_model=filter_model, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -114,6 +118,7 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[EMDenseReconstructionDatasetRead]: return _read_many( user_context=user_context, @@ -123,6 +128,7 @@ def read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -135,6 +141,7 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[EMDenseReconstructionDatasetRead]: return _read_many( user_context=user_context, @@ -144,6 +151,7 @@ def admin_read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) @@ -152,6 +160,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> EMDenseReconstructionDatasetRead: return router_read_one( id_=id_, @@ -160,12 +169,14 @@ def read_one( user_context=user_context, response_schema_class=EMDenseReconstructionDatasetRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> EMDenseReconstructionDatasetRead: return router_read_one( id_=id_, @@ -174,6 +185,7 @@ def admin_read_one( user_context=None, response_schema_class=EMDenseReconstructionDatasetRead, apply_operations=_load, + expand=expand, ) diff --git a/app/service/emodel.py b/app/service/emodel.py index 0152120ed..88445890b 100644 --- a/app/service/emodel.py +++ b/app/service/emodel.py @@ -15,6 +15,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -29,6 +30,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.emodel import ( EModelAdminUpdate, @@ -74,6 +76,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> EModelReadExpanded: return router_read_one( id_=id_, @@ -82,12 +85,14 @@ def read_one( user_context=user_context, response_schema_class=EModelReadExpanded, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> EModelReadExpanded: return router_read_one( db=db, @@ -96,6 +101,7 @@ def admin_read_one( user_context=None, response_schema_class=EModelReadExpanded, apply_operations=_load, + expand=expand, ) @@ -159,6 +165,7 @@ def _read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[EModelReadExpanded]: morphology_alias = aliased(CellMorphology, flat=True) @@ -210,6 +217,7 @@ def _read_many( filter_model=filter_model, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -221,6 +229,7 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[EModelReadExpanded]: return _read_many( user_context=user_context, @@ -230,6 +239,7 @@ def read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -242,6 +252,7 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[EModelReadExpanded]: return _read_many( user_context=user_context, @@ -251,6 +262,7 @@ def admin_read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/experimental_bouton_density.py b/app/service/experimental_bouton_density.py index 87ca74d9a..e5a087e48 100644 --- a/app/service/experimental_bouton_density.py +++ b/app/service/experimental_bouton_density.py @@ -18,6 +18,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -32,6 +33,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.density import ( ExperimentalBoutonDensityAdminUpdate, @@ -69,6 +71,7 @@ def _read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[ExperimentalBoutonDensityRead]: subject = aliased(Subject, flat=True) @@ -137,6 +140,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -148,6 +152,7 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[ExperimentalBoutonDensityRead]: return _read_many( user_context=user_context, @@ -157,6 +162,7 @@ def read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -169,6 +175,7 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[ExperimentalBoutonDensityRead]: return _read_many( user_context=user_context, @@ -178,6 +185,7 @@ def admin_read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) @@ -186,6 +194,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ExperimentalBoutonDensityRead: return router_read_one( db=db, @@ -194,12 +203,14 @@ def read_one( user_context=user_context, response_schema_class=ExperimentalBoutonDensityRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ExperimentalBoutonDensityRead: return router_read_one( db=db, @@ -208,6 +219,7 @@ def admin_read_one( user_context=None, response_schema_class=ExperimentalBoutonDensityRead, apply_operations=_load, + expand=expand, ) diff --git a/app/service/experimental_neuron_density.py b/app/service/experimental_neuron_density.py index 185cba062..eb381d985 100644 --- a/app/service/experimental_neuron_density.py +++ b/app/service/experimental_neuron_density.py @@ -17,6 +17,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -31,6 +32,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.density import ( ExperimentalNeuronDensityAdminUpdate, @@ -69,6 +71,7 @@ def _read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[ExperimentalNeuronDensityRead]: subject_alias = aliased(Subject, flat=True) @@ -128,6 +131,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -139,6 +143,7 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[ExperimentalNeuronDensityRead]: return _read_many( user_context=user_context, @@ -148,6 +153,7 @@ def read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -160,6 +166,7 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[ExperimentalNeuronDensityRead]: return _read_many( user_context=user_context, @@ -169,6 +176,7 @@ def admin_read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) @@ -177,6 +185,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ExperimentalNeuronDensityRead: return router_read_one( db=db, @@ -185,12 +194,14 @@ def read_one( user_context=user_context, response_schema_class=ExperimentalNeuronDensityRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ExperimentalNeuronDensityRead: return router_read_one( db=db, @@ -199,6 +210,7 @@ def admin_read_one( user_context=None, response_schema_class=ExperimentalNeuronDensityRead, apply_operations=_load, + expand=expand, ) diff --git a/app/service/experimental_synapses_per_connection.py b/app/service/experimental_synapses_per_connection.py index 530d3a646..0d0fca3a9 100644 --- a/app/service/experimental_synapses_per_connection.py +++ b/app/service/experimental_synapses_per_connection.py @@ -19,6 +19,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -33,6 +34,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.density import ( ExperimentalSynapsesPerConnectionAdminUpdate, @@ -75,6 +77,7 @@ def _read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[ExperimentalSynapsesPerConnectionRead]: subject_alias = aliased(Subject, flat=True) @@ -152,6 +155,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -163,6 +167,7 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[ExperimentalSynapsesPerConnectionRead]: return _read_many( user_context=user_context, @@ -172,6 +177,7 @@ def read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -184,6 +190,7 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[ExperimentalSynapsesPerConnectionRead]: return _read_many( user_context=user_context, @@ -193,6 +200,7 @@ def admin_read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) @@ -201,6 +209,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ExperimentalSynapsesPerConnectionRead: return router_read_one( db=db, @@ -209,12 +218,14 @@ def read_one( user_context=user_context, response_schema_class=ExperimentalSynapsesPerConnectionRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ExperimentalSynapsesPerConnectionRead: return router_read_one( db=db, @@ -223,6 +234,7 @@ def admin_read_one( user_context=None, response_schema_class=ExperimentalSynapsesPerConnectionRead, apply_operations=_load, + expand=expand, ) diff --git a/app/service/ion_channel_model.py b/app/service/ion_channel_model.py index 243285327..ec857dd7e 100644 --- a/app/service/ion_channel_model.py +++ b/app/service/ion_channel_model.py @@ -8,6 +8,7 @@ from app.db.model import Agent, Contribution, Ion, IonChannelModel, Person, Subject from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -23,6 +24,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.ion_channel_model import ( IonChannelModelAdminUpdate, @@ -68,6 +70,7 @@ def _read_many( filter_model: IonChannelModelFilterDep, in_brain_region: InBrainRegionDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[IonChannelModelExpanded]: agent_alias = aliased(Agent, flat=True) @@ -121,6 +124,7 @@ def _read_many( filter_model=filter_model, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -132,6 +136,7 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[IonChannelModelExpanded]: return _read_many( user_context=user_context, @@ -141,6 +146,7 @@ def read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -153,6 +159,7 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[IonChannelModelExpanded]: return _read_many( user_context=user_context, @@ -162,6 +169,7 @@ def admin_read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) @@ -170,6 +178,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> IonChannelModelExpanded: return router_read_one( id_=id_, @@ -178,12 +187,14 @@ def read_one( user_context=user_context, response_schema_class=IonChannelModelExpanded, apply_operations=_load_expanded, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> IonChannelModelExpanded: return router_read_one( id_=id_, @@ -192,6 +203,7 @@ def admin_read_one( user_context=None, response_schema_class=IonChannelModelExpanded, apply_operations=_load_expanded, + expand=expand, ) diff --git a/app/service/ion_channel_modeling_campaign.py b/app/service/ion_channel_modeling_campaign.py index 822231625..5ec1383de 100644 --- a/app/service/ion_channel_modeling_campaign.py +++ b/app/service/ion_channel_modeling_campaign.py @@ -12,6 +12,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -25,6 +26,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.ion_channel_modeling_campaign import ( IonChannelModelingCampaignAdminUpdate, @@ -55,6 +57,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> IonChannelModelingCampaignRead: return router_read_one( db=db, @@ -63,12 +66,14 @@ def read_one( user_context=user_context, response_schema_class=IonChannelModelingCampaignRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> IonChannelModelingCampaignRead: return router_read_one( db=db, @@ -77,6 +82,7 @@ def admin_read_one( user_context=None, response_schema_class=IonChannelModelingCampaignRead, apply_operations=_load, + expand=expand, ) @@ -139,6 +145,7 @@ def _read_many( filter_model: IonChannelModelingCampaignFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[IonChannelModelingCampaignRead]: agent_alias = aliased(Agent, flat=True) @@ -183,6 +190,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -193,6 +201,7 @@ def read_many( filter_model: IonChannelModelingCampaignFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[IonChannelModelingCampaignRead]: return _read_many( user_context=user_context, @@ -201,6 +210,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -212,6 +222,7 @@ def admin_read_many( filter_model: IonChannelModelingCampaignFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[IonChannelModelingCampaignRead]: return _read_many( user_context=user_context, @@ -220,6 +231,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/ion_channel_modeling_config.py b/app/service/ion_channel_modeling_config.py index d1b8ce8ee..34c1b23a6 100644 --- a/app/service/ion_channel_modeling_config.py +++ b/app/service/ion_channel_modeling_config.py @@ -11,6 +11,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -24,6 +25,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.ion_channel_modeling_config import ( IonChannelModelingConfigAdminUpdate, @@ -52,6 +54,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> IonChannelModelingConfigRead: return router_read_one( db=db, @@ -60,12 +63,14 @@ def read_one( user_context=user_context, response_schema_class=IonChannelModelingConfigRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> IonChannelModelingConfigRead: return router_read_one( db=db, @@ -74,6 +79,7 @@ def admin_read_one( user_context=None, response_schema_class=IonChannelModelingConfigRead, apply_operations=_load, + expand=expand, ) @@ -136,6 +142,7 @@ def _read_many( filter_model: IonChannelModelingConfigFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[IonChannelModelingConfigRead]: agent_alias = aliased(Agent, flat=True) @@ -178,6 +185,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -188,6 +196,7 @@ def read_many( filter_model: IonChannelModelingConfigFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[IonChannelModelingConfigRead]: return _read_many( user_context=user_context, @@ -196,6 +205,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -207,6 +217,7 @@ def admin_read_many( filter_model: IonChannelModelingConfigFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[IonChannelModelingConfigRead]: return _read_many( user_context=user_context, @@ -215,6 +226,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/ion_channel_recording.py b/app/service/ion_channel_recording.py index 2fcee1de0..a21db246e 100644 --- a/app/service/ion_channel_recording.py +++ b/app/service/ion_channel_recording.py @@ -13,6 +13,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -27,6 +28,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.ion_channel_recording import ( IonChannelRecordingAdminUpdate, @@ -66,6 +68,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> IonChannelRecordingRead: return router_read_one( db=db, @@ -74,12 +77,14 @@ def read_one( user_context=user_context, response_schema_class=IonChannelRecordingRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> IonChannelRecordingRead: return router_read_one( db=db, @@ -88,6 +93,7 @@ def admin_read_one( user_context=None, response_schema_class=IonChannelRecordingRead, apply_operations=_load, + expand=expand, ) @@ -115,6 +121,7 @@ def _read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[IonChannelRecordingRead]: agent_alias = aliased(Agent, flat=True) @@ -173,6 +180,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -184,6 +192,7 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[IonChannelRecordingRead]: return _read_many( user_context=user_context, @@ -193,6 +202,7 @@ def read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -205,6 +215,7 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[IonChannelRecordingRead]: return _read_many( user_context=user_context, @@ -214,6 +225,7 @@ def admin_read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/memodel.py b/app/service/memodel.py index 787b30827..775347d40 100644 --- a/app/service/memodel.py +++ b/app/service/memodel.py @@ -21,6 +21,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -35,6 +36,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.me_model import MEModelAdminUpdate, MEModelCreate, MEModelRead, MEModelUserUpdate from app.schemas.routers import DeleteResponse @@ -91,7 +93,12 @@ def _load(select: Select): ) -def read_one(db: SessionDep, id_: uuid.UUID, user_context: UserContextDep) -> MEModelRead: +def read_one( + db: SessionDep, + id_: uuid.UUID, + user_context: UserContextDep, + expand: ExpandDep = None, +) -> MEModelRead: return router_read_one( id_=id_, db=db, @@ -99,10 +106,11 @@ def read_one(db: SessionDep, id_: uuid.UUID, user_context: UserContextDep) -> ME user_context=user_context, response_schema_class=MEModelRead, apply_operations=_load, + expand=expand, ) -def admin_read_one(db: SessionDep, id_: uuid.UUID) -> MEModelRead: +def admin_read_one(db: SessionDep, id_: uuid.UUID, expand: ExpandDep = None) -> MEModelRead: return router_read_one( id_=id_, db=db, @@ -110,6 +118,7 @@ def admin_read_one(db: SessionDep, id_: uuid.UUID) -> MEModelRead: user_context=None, response_schema_class=MEModelRead, apply_operations=_load, + expand=expand, ) @@ -173,6 +182,7 @@ def _read_many( search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[MEModelRead]: morphology_alias = aliased(CellMorphology, flat=True) @@ -226,6 +236,7 @@ def _read_many( filter_model=filter_model, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -237,6 +248,7 @@ def read_many( search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[MEModelRead]: return _read_many( user_context=user_context, @@ -246,6 +258,7 @@ def read_many( search=search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -258,6 +271,7 @@ def admin_read_many( search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[MEModelRead]: return _read_many( user_context=user_context, @@ -267,6 +281,7 @@ def admin_read_many( search=search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/memodel_calibration_result.py b/app/service/memodel_calibration_result.py index e4b4c8896..4dada4764 100644 --- a/app/service/memodel_calibration_result.py +++ b/app/service/memodel_calibration_result.py @@ -7,6 +7,7 @@ from app.db.model import Agent, Contribution, MEModelCalibrationResult, Person from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -20,6 +21,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.memodel_calibration_result import ( MEModelCalibrationResultAdminUpdate, @@ -50,6 +52,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> MEModelCalibrationResultRead: return router_read_one( db=db, @@ -58,12 +61,14 @@ def read_one( user_context=user_context, response_schema_class=MEModelCalibrationResultRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> MEModelCalibrationResultRead: return router_read_one( db=db, @@ -72,6 +77,7 @@ def admin_read_one( user_context=None, response_schema_class=MEModelCalibrationResultRead, apply_operations=_load, + expand=expand, ) @@ -134,6 +140,7 @@ def _read_many( filter_model: MEModelCalibrationResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[MEModelCalibrationResultRead]: aliases: Aliases = { @@ -169,6 +176,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -179,6 +187,7 @@ def read_many( filter_model: MEModelCalibrationResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[MEModelCalibrationResultRead]: return _read_many( user_context=user_context, @@ -187,6 +196,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -198,6 +208,7 @@ def admin_read_many( filter_model: MEModelCalibrationResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[MEModelCalibrationResultRead]: return _read_many( user_context=user_context, @@ -206,6 +217,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/simulatable_extracellular_recording_array.py b/app/service/simulatable_extracellular_recording_array.py index 787e8e852..acc9e2f51 100644 --- a/app/service/simulatable_extracellular_recording_array.py +++ b/app/service/simulatable_extracellular_recording_array.py @@ -12,6 +12,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -27,6 +28,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.simulatable_extracellular_recording_array import ( @@ -58,6 +60,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SimulatableExtracellularRecordingArrayRead: return router_read_one( db=db, @@ -66,12 +69,14 @@ def read_one( user_context=user_context, response_schema_class=SimulatableExtracellularRecordingArrayRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SimulatableExtracellularRecordingArrayRead: return router_read_one( db=db, @@ -80,6 +85,7 @@ def admin_read_one( user_context=None, response_schema_class=SimulatableExtracellularRecordingArrayRead, apply_operations=_load, + expand=expand, ) @@ -142,6 +148,7 @@ def _read_many( filter_model: SimulatableExtracellularRecordingArrayFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[SimulatableExtracellularRecordingArrayRead]: agent_alias = aliased(Agent, flat=True) @@ -184,6 +191,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -194,6 +202,7 @@ def read_many( filter_model: SimulatableExtracellularRecordingArrayFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SimulatableExtracellularRecordingArrayRead]: return _read_many( user_context=user_context, @@ -202,6 +211,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -213,6 +223,7 @@ def admin_read_many( filter_model: SimulatableExtracellularRecordingArrayFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SimulatableExtracellularRecordingArrayRead]: return _read_many( user_context=user_context, @@ -221,6 +232,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/simulation.py b/app/service/simulation.py index 1e3cfb28c..6661297e3 100644 --- a/app/service/simulation.py +++ b/app/service/simulation.py @@ -12,6 +12,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -25,6 +26,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.simulation import ( @@ -53,6 +55,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SimulationRead: return router_read_one( db=db, @@ -61,12 +64,14 @@ def read_one( user_context=user_context, response_schema_class=SimulationRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SimulationRead: return router_read_one( db=db, @@ -75,6 +80,7 @@ def admin_read_one( user_context=None, response_schema_class=SimulationRead, apply_operations=_load, + expand=expand, ) @@ -137,6 +143,7 @@ def _read_many( filter_model: SimulationFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[SimulationRead]: agent_alias = aliased(Agent, flat=True) @@ -182,6 +189,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -192,6 +200,7 @@ def read_many( filter_model: SimulationFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SimulationRead]: return _read_many( user_context=user_context, @@ -200,6 +209,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -211,6 +221,7 @@ def admin_read_many( filter_model: SimulationFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SimulationRead]: return _read_many( user_context=user_context, @@ -219,6 +230,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/simulation_campaign.py b/app/service/simulation_campaign.py index 3dbe357ad..bf870a8b1 100644 --- a/app/service/simulation_campaign.py +++ b/app/service/simulation_campaign.py @@ -14,6 +14,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -27,6 +28,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.simulation_campaign import ( @@ -56,6 +58,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SimulationCampaignRead: return router_read_one( db=db, @@ -64,12 +67,14 @@ def read_one( user_context=user_context, response_schema_class=SimulationCampaignRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SimulationCampaignRead: return router_read_one( db=db, @@ -78,6 +83,7 @@ def admin_read_one( user_context=None, response_schema_class=SimulationCampaignRead, apply_operations=_load, + expand=expand, ) @@ -140,6 +146,7 @@ def _read_many( filter_model: SimulationCampaignFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[SimulationCampaignRead]: agent_alias = aliased(Agent, flat=True) @@ -190,6 +197,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -200,6 +208,7 @@ def read_many( filter_model: SimulationCampaignFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SimulationCampaignRead]: return _read_many( user_context=user_context, @@ -208,6 +217,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -219,6 +229,7 @@ def admin_read_many( filter_model: SimulationCampaignFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SimulationCampaignRead]: return _read_many( user_context=user_context, @@ -227,6 +238,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/simulation_result.py b/app/service/simulation_result.py index 8d7ce9f9d..2a383eaac 100644 --- a/app/service/simulation_result.py +++ b/app/service/simulation_result.py @@ -7,6 +7,7 @@ from app.db.model import Agent, Contribution, Person, SimulationResult from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -20,6 +21,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.simulation_result import ( @@ -51,6 +53,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SimulationResultRead: return router_read_one( db=db, @@ -59,12 +62,14 @@ def read_one( user_context=user_context, response_schema_class=SimulationResultRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SimulationResultRead: return router_read_one( db=db, @@ -73,6 +78,7 @@ def admin_read_one( user_context=None, response_schema_class=SimulationResultRead, apply_operations=_load, + expand=expand, ) @@ -135,6 +141,7 @@ def _read_many( filter_model: SimulationResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[SimulationResultRead]: agent_alias = aliased(Agent, flat=True) @@ -177,6 +184,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -187,6 +195,7 @@ def read_many( filter_model: SimulationResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SimulationResultRead]: return _read_many( user_context=user_context, @@ -195,6 +204,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -206,6 +216,7 @@ def admin_read_many( filter_model: SimulationResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SimulationResultRead]: return _read_many( user_context=user_context, @@ -214,6 +225,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/single_neuron_simulation.py b/app/service/single_neuron_simulation.py index 1a6f514fb..df78e8f02 100644 --- a/app/service/single_neuron_simulation.py +++ b/app/service/single_neuron_simulation.py @@ -6,6 +6,7 @@ from app.db.model import Agent, Contribution, MEModel, Person, SingleNeuronSimulation from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -20,6 +21,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.simulation import ( @@ -51,6 +53,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SingleNeuronSimulationRead: return router_read_one( db=db, @@ -59,12 +62,14 @@ def read_one( user_context=user_context, response_schema_class=SingleNeuronSimulationRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SingleNeuronSimulationRead: return router_read_one( db=db, @@ -73,6 +78,7 @@ def admin_read_one( user_context=None, response_schema_class=SingleNeuronSimulationRead, apply_operations=_load, + expand=expand, ) @@ -136,6 +142,7 @@ def _read_many( with_search: SearchDep, in_brain_region: InBrainRegionDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[SingleNeuronSimulationRead]: me_model_alias = aliased(MEModel, flat=True) @@ -185,6 +192,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -196,6 +204,7 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[SingleNeuronSimulationRead]: return _read_many( user_context=user_context, @@ -205,6 +214,7 @@ def read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -217,6 +227,7 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[SingleNeuronSimulationRead]: return _read_many( user_context=user_context, @@ -226,6 +237,7 @@ def admin_read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/single_neuron_synaptome.py b/app/service/single_neuron_synaptome.py index ea5991ffb..d63cce036 100644 --- a/app/service/single_neuron_synaptome.py +++ b/app/service/single_neuron_synaptome.py @@ -6,6 +6,7 @@ from app.db.model import Agent, Contribution, MEModel, Person, SingleNeuronSynaptome from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -20,6 +21,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.synaptome import ( @@ -49,6 +51,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SingleNeuronSynaptomeRead: return router_read_one( db=db, @@ -57,12 +60,14 @@ def read_one( user_context=user_context, response_schema_class=SingleNeuronSynaptomeRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SingleNeuronSynaptomeRead: return router_read_one( db=db, @@ -71,6 +76,7 @@ def admin_read_one( user_context=None, response_schema_class=SingleNeuronSynaptomeRead, apply_operations=_load, + expand=expand, ) @@ -134,6 +140,7 @@ def _read_many( with_search: SearchDep, in_brain_region: InBrainRegionDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[SingleNeuronSynaptomeRead]: me_model_alias = aliased(MEModel, flat=True) @@ -185,6 +192,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -196,6 +204,7 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[SingleNeuronSynaptomeRead]: return _read_many( user_context=user_context, @@ -205,6 +214,7 @@ def read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -217,6 +227,7 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[SingleNeuronSynaptomeRead]: return _read_many( user_context=user_context, @@ -226,6 +237,7 @@ def admin_read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/single_neuron_synaptome_simulation.py b/app/service/single_neuron_synaptome_simulation.py index 9b21f99d1..d03ae356e 100644 --- a/app/service/single_neuron_synaptome_simulation.py +++ b/app/service/single_neuron_synaptome_simulation.py @@ -13,6 +13,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, InBrainRegionDep, PaginationQuery, @@ -29,6 +30,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.simulation import ( @@ -64,6 +66,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SingleNeuronSynaptomeSimulationRead: return router_read_one( db=db, @@ -72,12 +75,14 @@ def read_one( db_model_class=SingleNeuronSynaptomeSimulation, response_schema_class=SingleNeuronSynaptomeSimulationRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SingleNeuronSynaptomeSimulationRead: return router_read_one( db=db, @@ -86,6 +91,7 @@ def admin_read_one( db_model_class=SingleNeuronSynaptomeSimulation, response_schema_class=SingleNeuronSynaptomeSimulationRead, apply_operations=_load, + expand=expand, ) @@ -149,6 +155,7 @@ def _read_many( with_search: SearchDep, in_brain_region: InBrainRegionDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[SingleNeuronSynaptomeSimulationRead]: synaptome_alias = aliased(SingleNeuronSynaptome, flat=True) @@ -201,6 +208,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -212,6 +220,7 @@ def read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[SingleNeuronSynaptomeSimulationRead]: return _read_many( user_context=user_context, @@ -221,6 +230,7 @@ def read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=True, ) @@ -233,6 +243,7 @@ def admin_read_many( with_search: SearchDep, facets: FacetsDep, in_brain_region: InBrainRegionDep, + expand: ExpandDep = None, ) -> ListResponse[SingleNeuronSynaptomeSimulationRead]: return _read_many( user_context=user_context, @@ -242,6 +253,7 @@ def admin_read_many( with_search=with_search, facets=facets, in_brain_region=in_brain_region, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/skeletonization_campaign.py b/app/service/skeletonization_campaign.py index 52438d79d..3d75220bc 100644 --- a/app/service/skeletonization_campaign.py +++ b/app/service/skeletonization_campaign.py @@ -7,6 +7,7 @@ from app.db.model import Agent, Person, SkeletonizationCampaign, SkeletonizationConfig from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -20,6 +21,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.skeletonization_campaign import ( @@ -58,6 +60,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ReadSchema: return router_read_one( db=db, @@ -66,12 +69,14 @@ def read_one( user_context=user_context, response_schema_class=ReadSchema, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ReadSchema: return router_read_one( db=db, @@ -80,6 +85,7 @@ def admin_read_one( user_context=None, response_schema_class=ReadSchema, apply_operations=_load, + expand=expand, ) @@ -142,6 +148,7 @@ def _read_many( filter_model: FilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[ReadSchema]: agent_alias = aliased(Agent, flat=True) @@ -186,6 +193,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -196,6 +204,7 @@ def read_many( filter_model: FilterDep, with_search: SearchDep, with_facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[ReadSchema]: return _read_many( user_context=user_context, @@ -204,6 +213,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=with_facets, + expand=expand, check_authorized_project=True, ) @@ -215,6 +225,7 @@ def admin_read_many( filter_model: FilterDep, with_search: SearchDep, with_facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[ReadSchema]: return _read_many( user_context=user_context, @@ -223,6 +234,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=with_facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/skeletonization_config.py b/app/service/skeletonization_config.py index 1260869a4..a05e6be57 100644 --- a/app/service/skeletonization_config.py +++ b/app/service/skeletonization_config.py @@ -11,6 +11,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -24,6 +25,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.skeletonization_config import ( @@ -59,6 +61,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ReadSchema: return router_read_one( db=db, @@ -67,12 +70,14 @@ def read_one( user_context=user_context, response_schema_class=ReadSchema, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ReadSchema: return router_read_one( db=db, @@ -81,6 +86,7 @@ def admin_read_one( user_context=None, response_schema_class=ReadSchema, apply_operations=_load, + expand=expand, ) @@ -143,6 +149,7 @@ def _read_many( filter_model: FilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[ReadSchema]: agent_alias = aliased(Agent, flat=True) @@ -185,6 +192,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -195,6 +203,7 @@ def read_many( filter_model: SkeletonizationConfigFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SkeletonizationConfigRead]: return _read_many( user_context=user_context, @@ -203,6 +212,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -214,6 +224,7 @@ def admin_read_many( filter_model: SkeletonizationConfigFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SkeletonizationConfigRead]: return _read_many( user_context=user_context, @@ -222,6 +233,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/subject.py b/app/service/subject.py index 11b9d0d3e..28935f309 100644 --- a/app/service/subject.py +++ b/app/service/subject.py @@ -6,7 +6,7 @@ from app.db.model import Agent, Contribution, Person, Subject from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep -from app.dependencies.common import FacetsDep, PaginationQuery, SearchDep +from app.dependencies.common import ExpandDep, FacetsDep, PaginationQuery, SearchDep from app.dependencies.db import SessionDep from app.filters.subject import SubjectFilterDep from app.queries.common import ( @@ -16,6 +16,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.subject import SubjectAdminUpdate, SubjectCreate, SubjectRead, SubjectUserUpdate @@ -43,6 +44,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SubjectRead: return router_read_one( db=db, @@ -51,12 +53,14 @@ def read_one( user_context=user_context, response_schema_class=SubjectRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> SubjectRead: return router_read_one( db=db, @@ -65,6 +69,7 @@ def admin_read_one( user_context=None, response_schema_class=SubjectRead, apply_operations=_load, + expand=expand, ) @@ -127,6 +132,7 @@ def _read_many( filter_model: SubjectFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[SubjectRead]: aliases: Aliases = { @@ -168,6 +174,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -178,6 +185,7 @@ def read_many( filter_model: SubjectFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SubjectRead]: return _read_many( user_context=user_context, @@ -186,6 +194,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -197,6 +206,7 @@ def admin_read_many( filter_model: SubjectFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[SubjectRead]: return _read_many( user_context=user_context, @@ -205,6 +215,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/task_config.py b/app/service/task_config.py index cb68c230e..087483be1 100644 --- a/app/service/task_config.py +++ b/app/service/task_config.py @@ -11,6 +11,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -24,6 +25,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.task_config import ( @@ -60,6 +62,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ReadSchema: return router_read_one( db=db, @@ -68,12 +71,14 @@ def read_one( user_context=user_context, response_schema_class=ReadSchema, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ReadSchema: return router_read_one( db=db, @@ -82,6 +87,7 @@ def admin_read_one( user_context=None, response_schema_class=ReadSchema, apply_operations=_load, + expand=expand, ) @@ -144,6 +150,7 @@ def _read_many( filter_model: FilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[ReadSchema]: agent_alias = aliased(Agent, flat=True) @@ -186,6 +193,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -196,6 +204,7 @@ def read_many( filter_model: FilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[ReadSchema]: return _read_many( user_context=user_context, @@ -204,6 +213,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -215,6 +225,7 @@ def admin_read_many( filter_model: FilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[ReadSchema]: return _read_many( user_context=user_context, @@ -223,6 +234,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/task_result.py b/app/service/task_result.py index 7ff370fe2..1f817e856 100644 --- a/app/service/task_result.py +++ b/app/service/task_result.py @@ -13,6 +13,7 @@ ) from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -26,6 +27,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.task_result import ( @@ -57,6 +59,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> TaskResultRead: return router_read_one( db=db, @@ -65,12 +68,14 @@ def read_one( user_context=user_context, response_schema_class=TaskResultRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> TaskResultRead: return router_read_one( db=db, @@ -79,6 +84,7 @@ def admin_read_one( user_context=None, response_schema_class=TaskResultRead, apply_operations=_load, + expand=expand, ) @@ -141,6 +147,7 @@ def _read_many( filter_model: TaskResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[TaskResultRead]: agent_alias = aliased(Agent, flat=True) @@ -183,6 +190,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -193,6 +201,7 @@ def read_many( filter_model: TaskResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[TaskResultRead]: return _read_many( user_context=user_context, @@ -201,6 +210,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -212,6 +222,7 @@ def admin_read_many( filter_model: TaskResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[TaskResultRead]: return _read_many( user_context=user_context, @@ -220,6 +231,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/app/service/validation_result.py b/app/service/validation_result.py index 5034acdca..8b45c45c1 100644 --- a/app/service/validation_result.py +++ b/app/service/validation_result.py @@ -7,6 +7,7 @@ from app.db.model import Contribution, Person, Subject, ValidationResult from app.dependencies.auth import AdminContextDep, UserContextDep, UserContextWithProjectIdDep from app.dependencies.common import ( + ExpandDep, FacetsDep, PaginationQuery, SearchDep, @@ -20,6 +21,7 @@ router_update_one, router_user_delete_one, ) +from app.queries.expand import EntityExpand from app.queries.factory import query_params_factory from app.schemas.routers import DeleteResponse from app.schemas.types import ListResponse @@ -52,6 +54,7 @@ def read_one( user_context: UserContextDep, db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ValidationResultRead: return router_read_one( db=db, @@ -60,12 +63,14 @@ def read_one( user_context=user_context, response_schema_class=ValidationResultRead, apply_operations=_load, + expand=expand, ) def admin_read_one( db: SessionDep, id_: uuid.UUID, + expand: ExpandDep = None, ) -> ValidationResultRead: return router_read_one( db=db, @@ -74,6 +79,7 @@ def admin_read_one( user_context=None, response_schema_class=ValidationResultRead, apply_operations=_load, + expand=expand, ) @@ -136,6 +142,7 @@ def _read_many( filter_model: ValidationResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: set[EntityExpand] | None, check_authorized_project: bool, ) -> ListResponse[ValidationResultRead]: aliases: Aliases = { @@ -170,6 +177,7 @@ def _read_many( authorized_project_id=user_context.project_id, filter_joins=filter_joins, check_authorized_project=check_authorized_project, + expand=expand, ) @@ -180,6 +188,7 @@ def read_many( filter_model: ValidationResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[ValidationResultRead]: return _read_many( user_context=user_context, @@ -188,6 +197,7 @@ def read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=True, ) @@ -199,6 +209,7 @@ def admin_read_many( filter_model: ValidationResultFilterDep, with_search: SearchDep, facets: FacetsDep, + expand: ExpandDep = None, ) -> ListResponse[ValidationResultRead]: return _read_many( user_context=user_context, @@ -207,6 +218,7 @@ def admin_read_many( filter_model=filter_model, with_search=with_search, facets=facets, + expand=expand, check_authorized_project=False, ) diff --git a/tests/test_brain_atlas.py b/tests/test_brain_atlas.py index e4cb51a50..1920a5333 100644 --- a/tests/test_brain_atlas.py +++ b/tests/test_brain_atlas.py @@ -128,6 +128,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas", "contributions": [], + "generated_derivations": None, + "used_derivations": None, } response = client.get(ROUTE) @@ -202,6 +204,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas_region", "contributions": [], + "generated_derivations": None, + "used_derivations": None, }, { "assets": [expected_asset], @@ -219,6 +223,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas_region", "contributions": [], + "generated_derivations": None, + "used_derivations": None, }, { "assets": [expected_asset], @@ -236,6 +242,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas_region", "contributions": [], + "generated_derivations": None, + "used_derivations": None, }, { "assets": [expected_asset], @@ -253,6 +261,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas_region", "contributions": [], + "generated_derivations": None, + "used_derivations": None, }, ] @@ -281,6 +291,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas_region", "contributions": [], + "generated_derivations": None, + "used_derivations": None, } diff --git a/tests/test_cell_morphology_protocol.py b/tests/test_cell_morphology_protocol.py index 33d6babf4..2539ee691 100644 --- a/tests/test_cell_morphology_protocol.py +++ b/tests/test_cell_morphology_protocol.py @@ -149,6 +149,8 @@ def _assert_read_response(actual, expected): "id", "contributions", "lifecycle_status", + "generated_derivations", + "used_derivations", } assert ignored_keys.issubset(actual) actual = {k: v for k, v in actual.items() if k not in ignored_keys} diff --git a/tests/test_circuit.py b/tests/test_circuit.py index a2168ddde..72d4d6c98 100644 --- a/tests/test_circuit.py +++ b/tests/test_circuit.py @@ -461,14 +461,14 @@ def ids(params): # the derived children are not on the used side of these derivations assert ids({"used_derivation__derivation_type": "circuit_customization"}) == set() - # the filter alone does not add derivation columns to the response + # the filter alone does not load the derivation lists: they serialize as null data = assert_request( client.get, url=ROUTE, params={"generated_derivation__derivation_type": "circuit_extraction"}, ).json()["data"] - assert "generated_derivations" not in data[0] - assert "used_derivations" not in data[0] + assert data[0]["generated_derivations"] is None + assert data[0]["used_derivations"] is None def test_filter_by_derivation_type_non_circuit_source(db, client, circuit, emodel_id, person_id): @@ -501,10 +501,10 @@ def get_by_id(entity_id, params=None): data = assert_request(client.get, url=ROUTE, params=params or {}).json()["data"] return next(d for d in data if d["id"] == str(entity_id)) - # no expand -> fields are absent entirely (no extra query, no null columns) + # no expand -> the derivation lists are not loaded and serialize as null child = get_by_id(circuit.id) - assert "generated_derivations" not in child - assert "used_derivations" not in child + assert child["generated_derivations"] is None + assert child["used_derivations"] is None # expand=generated_derivations -> populated on the child; other direction is null child = get_by_id(circuit.id, {"expand": "generated_derivations"}) diff --git a/tests/test_emodel.py b/tests/test_emodel.py index 144491f16..1743ac475 100644 --- a/tests/test_emodel.py +++ b/tests/test_emodel.py @@ -4,13 +4,14 @@ import pytest from fastapi.testclient import TestClient -from app.db.model import EModel -from app.db.types import EntityType +from app.db.model import Derivation, EModel +from app.db.types import DerivationType, EntityType from .conftest import CreateIds, EModelIds from .utils import ( TEST_DATA_DIR, USER_SUB_ID_1, + add_db, assert_request, check_entity_delete_one, check_entity_read_many, @@ -556,3 +557,69 @@ def req(query): data = req({"lifecycle_status": "active"}) assert len(data) == n_models + + +def test_derivation_filter_and_expand(db, client, create_emodel_ids, person_id): + """Derivation filters + expand are inherited by every entity, here proven on /emodel. + + Covers both directions, the derivation_type filter, the related-entity-id filter, and the + expand read lists on both read_many and read_one. + """ + used_id, generated_id = create_emodel_ids(2) + add_db( + db, + Derivation( + used_id=uuid.UUID(used_id), + generated_id=uuid.UUID(generated_id), + derivation_type=DerivationType.circuit_extraction, + label="gen", + created_by_id=person_id, + updated_by_id=person_id, + ), + ) + + def ids(params): + return { + d["id"] for d in assert_request(client.get, url=ROUTE, params=params).json()["data"] + } + + # filter by derivation_type, both directions (+ __in) + assert ids({"generated_derivation__derivation_type": "circuit_extraction"}) == {generated_id} + assert ids({"used_derivation__derivation_type": "circuit_extraction"}) == {used_id} + assert ids({"generated_derivation__derivation_type__in": ["circuit_extraction"]}) == { + generated_id + } + + # filter by the related entity id (the added requirement), both directions (+ __in) + assert ids({"generated_derivation__used_id": used_id}) == {generated_id} + assert ids({"used_derivation__generated_id": generated_id}) == {used_id} + assert ids({"generated_derivation__used_id__in": [used_id]}) == {generated_id} + + # default read: derivation lists are not loaded, serialize as null + plain = assert_request(client.get, url=f"{ROUTE}/{generated_id}").json() + assert plain["generated_derivations"] is None + assert plain["used_derivations"] is None + + # expand on read_one, per direction + expanded = assert_request( + client.get, url=f"{ROUTE}/{generated_id}", params={"expand": "generated_derivations"} + ).json() + assert expanded["used_derivations"] is None + assert len(expanded["generated_derivations"]) == 1 + entry = expanded["generated_derivations"][0] + assert entry["used"]["id"] == used_id + assert entry["used"]["type"] == EntityType.emodel + assert entry["derivation_type"] == DerivationType.circuit_extraction + assert entry["label"] == "gen" + + # expand on read_many for the source side + src = next( + d + for d in assert_request( + client.get, url=ROUTE, params={"expand": "used_derivations"} + ).json()["data"] + if d["id"] == used_id + ) + assert src["generated_derivations"] is None + assert len(src["used_derivations"]) == 1 + assert src["used_derivations"][0]["generated"]["id"] == generated_id diff --git a/tests/test_ion_channel_recording.py b/tests/test_ion_channel_recording.py index aad208b78..4b1ce75c2 100644 --- a/tests/test_ion_channel_recording.py +++ b/tests/test_ion_channel_recording.py @@ -57,6 +57,8 @@ def _assert_read_response(actual, expected): "update_date": ANY, "updated_by": ANY, "lifecycle_status": "active", + "generated_derivations": None, + "used_derivations": None, } | { k: v for k, v in expected.items() From b2dacce4d55af7b3ded3c5a81af4229dfd35ce03 Mon Sep 17 00:00:00 2001 From: Pavlo Getta Date: Tue, 30 Jun 2026 09:24:41 +0200 Subject: [PATCH 4/6] Address review comments on derivation filters/expand - Rename expand keys/properties: generated_derivations -> generated_from_derivations and used_derivations -> used_by_derivations (params, response fields, properties) - Remove unreachable try/except in CustomFilter nested-field helpers so a genuine registered-join/undeclared-field mismatch fails loudly - Drop redundant inherited-derivation comment in CircuitFilter - Narrow _is_entity_model param from Any to object --- app/db/model.py | 4 +-- app/dependencies/common.py | 3 +- app/filters/base.py | 12 ++----- app/filters/circuit.py | 3 -- app/queries/expand.py | 17 +++++----- app/queries/factory.py | 2 +- app/schemas/entity.py | 4 +-- app/service/cell_morphology.py | 4 +-- app/service/em_cell_mesh.py | 4 +-- tests/test_brain_atlas.py | 24 +++++++------- tests/test_cell_morphology_protocol.py | 4 +-- tests/test_circuit.py | 46 +++++++++++++------------- tests/test_emodel.py | 20 +++++------ tests/test_ion_channel_recording.py | 4 +-- 14 files changed, 71 insertions(+), 80 deletions(-) diff --git a/app/db/model.py b/app/db/model.py index bd1da94b3..2987fda6d 100644 --- a/app/db/model.py +++ b/app/db/model.py @@ -654,14 +654,14 @@ def __table_args__(cls): # noqa: D105, PLW3201 ) @property - def generated_derivations(self) -> list["Derivation"] | None: + def generated_from_derivations(self) -> list["Derivation"] | None: """Derivations where this entity is the generated entity, or None if not expanded.""" if "derivations_as_generated" in sa.inspect(self).unloaded: return None return self.derivations_as_generated @property - def used_derivations(self) -> list["Derivation"] | None: + def used_by_derivations(self) -> list["Derivation"] | None: """Derivations where this entity is the used entity, or None if not expanded.""" if "derivations_as_used" in sa.inspect(self).unloaded: return None diff --git a/app/dependencies/common.py b/app/dependencies/common.py index d3e18d128..2529d540c 100644 --- a/app/dependencies/common.py +++ b/app/dependencies/common.py @@ -234,5 +234,6 @@ class DerivationQuery(BaseModel): SearchDep = Annotated[Search, Depends()] InBrainRegionDep = Annotated[InBrainRegionQuery, Depends()] DerivationQueryDep = Annotated[DerivationQuery, Depends()] -# `?expand=generated_derivations&expand=used_derivations` — available on every entity read endpoint. +# `?expand=generated_from_derivations&expand=used_by_derivations` — available on every entity +# read endpoint. ExpandDep = Annotated[set[EntityExpand] | None, Query()] diff --git a/app/filters/base.py b/app/filters/base.py index 57697412c..3fc378a66 100644 --- a/app/filters/base.py +++ b/app/filters/base.py @@ -240,12 +240,7 @@ def has_nested_filtering_field(self, name: str) -> bool: name: The name of the nested filtering field. It's possible to specify deeply nested filtering fields using the dot notation, e.g. "measurement_kind.pref_label". """ - try: - attr = attrgetter(name)(self) - except AttributeError: - # A join may be registered (e.g. the derivation joins, added for every entity model) - # without this filter declaring the matching field; treat it as not filtering. - return False + attr = attrgetter(name)(self) # ignore nested filters because they are not valid fields return not isinstance(attr, CustomFilter) and attr is not None @@ -256,10 +251,7 @@ def get_nested_filter(self, name: str) -> "CustomFilter[T] | None": name: The name of the nested filter. It's possible to specify deeply nested filters using the dot notation, e.g. "measurement_annotation.measurement_kind". """ - try: - attr = attrgetter(name)(self) - except AttributeError: - return None + attr = attrgetter(name)(self) if isinstance(attr, CustomFilter) and attr.has_filtering_fields(): return attr return None diff --git a/app/filters/circuit.py b/app/filters/circuit.py index 47843612d..6eb3c93ba 100644 --- a/app/filters/circuit.py +++ b/app/filters/circuit.py @@ -56,9 +56,6 @@ class CircuitFilter( number_connections__lte: int | None = None number_connections__gte: int | None = None - # generated_derivation / used_derivation are inherited from EntityFilterMixin via - # ScientificArtifactFilter, so every entity filter exposes them. - order_by: list[str] = ["-creation_date"] # noqa: RUF012 class Constants(ScientificArtifactFilter.Constants): diff --git a/app/queries/expand.py b/app/queries/expand.py index 6d35b8d60..f67c17482 100644 --- a/app/queries/expand.py +++ b/app/queries/expand.py @@ -1,9 +1,10 @@ """Shared, entity-wide `expand` support for on-demand derivation lists. -Every entity read schema carries the load-aware ``generated_derivations`` / ``used_derivations`` -fields (see app.schemas.entity.DerivationReadMixin); they serialize as ``null`` unless the matching -direction was eagerly loaded. This module centralizes the enum the read endpoints expose as the -``expand`` query param and the loader options that populate the relationships. +Every entity read schema carries the load-aware ``generated_from_derivations`` / +``used_by_derivations`` fields (see app.schemas.entity.DerivationReadMixin); they serialize as +``null`` unless the matching direction was eagerly loaded. This module centralizes the enum the +read endpoints expose as the ``expand`` query param and the loader options that populate the +relationships. """ from collections.abc import Set as AbstractSet @@ -18,8 +19,8 @@ class EntityExpand(StrEnum): """Derivation lists that any entity endpoint can load on demand via ``?expand=``.""" - generated_derivations = auto() - used_derivations = auto() + generated_from_derivations = auto() + used_by_derivations = auto() def apply_derivation_expand( @@ -34,11 +35,11 @@ def apply_derivation_expand( """ if not expand or not issubclass(db_model_class, Entity): return query - if EntityExpand.generated_derivations in expand: + if EntityExpand.generated_from_derivations in expand: query = query.options( selectinload(db_model_class.derivations_as_generated).joinedload(Derivation.used) ) - if EntityExpand.used_derivations in expand: + if EntityExpand.used_by_derivations in expand: query = query.options( selectinload(db_model_class.derivations_as_used).joinedload(Derivation.generated) ) diff --git a/app/queries/factory.py b/app/queries/factory.py index 3635a11f3..b171a5609 100644 --- a/app/queries/factory.py +++ b/app/queries/factory.py @@ -48,7 +48,7 @@ from app.queries.types import ApplyOperations -def _is_entity_model(db_model_class: Any) -> bool: +def _is_entity_model(db_model_class: object) -> bool: """Whether the model is an Entity subclass (kept separate to avoid narrowing the caller).""" return isinstance(db_model_class, type) and issubclass(db_model_class, Entity) diff --git a/app/schemas/entity.py b/app/schemas/entity.py index 0b730717d..4661114ab 100644 --- a/app/schemas/entity.py +++ b/app/schemas/entity.py @@ -59,8 +59,8 @@ class DerivationReadMixin: serializes as ``[]``. """ - generated_derivations: list[GeneratedDerivationRead] | None = None - used_derivations: list[UsedDerivationRead] | None = None + generated_from_derivations: list[GeneratedDerivationRead] | None = None + used_by_derivations: list[UsedDerivationRead] | None = None from app.schemas.contribution import ContributionReadWithoutEntityMixin # noqa: E402 diff --git a/app/service/cell_morphology.py b/app/service/cell_morphology.py index 5e321f936..5ed05414e 100644 --- a/app/service/cell_morphology.py +++ b/app/service/cell_morphology.py @@ -56,8 +56,8 @@ class ExpandableAttribute(StrEnum): measurement_annotation = auto() # Inherited entity-wide derivation expands; loaded centrally (see apply_derivation_expand). - generated_derivations = auto() - used_derivations = auto() + generated_from_derivations = auto() + used_by_derivations = auto() def _load_from_db(query: sa.Select, *, expand: set[ExpandableAttribute] | None = None) -> sa.Select: diff --git a/app/service/em_cell_mesh.py b/app/service/em_cell_mesh.py index b1666fadd..368d21699 100644 --- a/app/service/em_cell_mesh.py +++ b/app/service/em_cell_mesh.py @@ -48,8 +48,8 @@ class Expandable(StrEnum): measurement_annotation = auto() # Inherited entity-wide derivation expands; loaded centrally (see apply_derivation_expand). - generated_derivations = auto() - used_derivations = auto() + generated_from_derivations = auto() + used_by_derivations = auto() def _load(q: Select[EMCellMesh], *, expand: set[Expandable] | None = None) -> Select[EMCellMesh]: diff --git a/tests/test_brain_atlas.py b/tests/test_brain_atlas.py index 1920a5333..0689766b2 100644 --- a/tests/test_brain_atlas.py +++ b/tests/test_brain_atlas.py @@ -128,8 +128,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas", "contributions": [], - "generated_derivations": None, - "used_derivations": None, + "generated_from_derivations": None, + "used_by_derivations": None, } response = client.get(ROUTE) @@ -204,8 +204,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas_region", "contributions": [], - "generated_derivations": None, - "used_derivations": None, + "generated_from_derivations": None, + "used_by_derivations": None, }, { "assets": [expected_asset], @@ -223,8 +223,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas_region", "contributions": [], - "generated_derivations": None, - "used_derivations": None, + "generated_from_derivations": None, + "used_by_derivations": None, }, { "assets": [expected_asset], @@ -242,8 +242,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas_region", "contributions": [], - "generated_derivations": None, - "used_derivations": None, + "generated_from_derivations": None, + "used_by_derivations": None, }, { "assets": [expected_asset], @@ -261,8 +261,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas_region", "contributions": [], - "generated_derivations": None, - "used_derivations": None, + "generated_from_derivations": None, + "used_by_derivations": None, }, ] @@ -291,8 +291,8 @@ def test_brain_atlas(db, client, species_id, person_id): "lifecycle_status": "active", "type": "brain_atlas_region", "contributions": [], - "generated_derivations": None, - "used_derivations": None, + "generated_from_derivations": None, + "used_by_derivations": None, } diff --git a/tests/test_cell_morphology_protocol.py b/tests/test_cell_morphology_protocol.py index 2539ee691..7b2d3c72f 100644 --- a/tests/test_cell_morphology_protocol.py +++ b/tests/test_cell_morphology_protocol.py @@ -149,8 +149,8 @@ def _assert_read_response(actual, expected): "id", "contributions", "lifecycle_status", - "generated_derivations", - "used_derivations", + "generated_from_derivations", + "used_by_derivations", } assert ignored_keys.issubset(actual) actual = {k: v for k, v in actual.items() if k not in ignored_keys} diff --git a/tests/test_circuit.py b/tests/test_circuit.py index 72d4d6c98..dcb5a394c 100644 --- a/tests/test_circuit.py +++ b/tests/test_circuit.py @@ -467,8 +467,8 @@ def ids(params): url=ROUTE, params={"generated_derivation__derivation_type": "circuit_extraction"}, ).json()["data"] - assert data[0]["generated_derivations"] is None - assert data[0]["used_derivations"] is None + assert data[0]["generated_from_derivations"] is None + assert data[0]["used_by_derivations"] is None def test_filter_by_derivation_type_non_circuit_source(db, client, circuit, emodel_id, person_id): @@ -503,37 +503,37 @@ def get_by_id(entity_id, params=None): # no expand -> the derivation lists are not loaded and serialize as null child = get_by_id(circuit.id) - assert child["generated_derivations"] is None - assert child["used_derivations"] is None - - # expand=generated_derivations -> populated on the child; other direction is null - child = get_by_id(circuit.id, {"expand": "generated_derivations"}) - assert child["used_derivations"] is None - assert len(child["generated_derivations"]) == 1 - entry = child["generated_derivations"][0] + assert child["generated_from_derivations"] is None + assert child["used_by_derivations"] is None + + # expand=generated_from_derivations -> populated on the child; other direction is null + child = get_by_id(circuit.id, {"expand": "generated_from_derivations"}) + assert child["used_by_derivations"] is None + assert len(child["generated_from_derivations"]) == 1 + entry = child["generated_from_derivations"][0] assert entry["used"]["id"] == str(root_circuit.id) assert entry["used"]["type"] == EntityType.circuit assert entry["derivation_type"] == DerivationType.circuit_extraction assert entry["label"] == "extracted" - # expand=used_derivations -> populated on the parent; other direction is null - parent = get_by_id(root_circuit.id, {"expand": "used_derivations"}) - assert parent["generated_derivations"] is None - assert len(parent["used_derivations"]) == 1 - entry = parent["used_derivations"][0] + # expand=used_by_derivations -> populated on the parent; other direction is null + parent = get_by_id(root_circuit.id, {"expand": "used_by_derivations"}) + assert parent["generated_from_derivations"] is None + assert len(parent["used_by_derivations"]) == 1 + entry = parent["used_by_derivations"][0] assert entry["generated"]["id"] == str(circuit.id) assert entry["generated"]["type"] == EntityType.circuit assert entry["derivation_type"] == DerivationType.circuit_extraction assert entry["label"] == "extracted" # expand both -> a direction with no derivations is an empty list (not null) - params = {"expand": ["generated_derivations", "used_derivations"]} + params = {"expand": ["generated_from_derivations", "used_by_derivations"]} child = get_by_id(circuit.id, params) parent = get_by_id(root_circuit.id, params) - assert len(child["generated_derivations"]) == 1 - assert child["used_derivations"] == [] - assert parent["generated_derivations"] == [] - assert len(parent["used_derivations"]) == 1 + assert len(child["generated_from_derivations"]) == 1 + assert child["used_by_derivations"] == [] + assert parent["generated_from_derivations"] == [] + assert len(parent["used_by_derivations"]) == 1 def test_filter_and_expand_combined(db, client, root_circuit, circuit, public_circuit, person_id): @@ -558,12 +558,12 @@ def test_filter_and_expand_combined(db, client, root_circuit, circuit, public_ci url=ROUTE, params={ "generated_derivation__derivation_type": "circuit_extraction", - "expand": "generated_derivations", + "expand": "generated_from_derivations", }, ).json()["data"] assert {d["id"] for d in data} == {str(circuit.id)} - assert data[0]["generated_derivations"][0]["used"]["id"] == str(root_circuit.id) - assert data[0]["used_derivations"] is None + assert data[0]["generated_from_derivations"][0]["used"]["id"] == str(root_circuit.id) + assert data[0]["used_by_derivations"] is None def test_derivation_filter_pagination_no_duplicates( diff --git a/tests/test_emodel.py b/tests/test_emodel.py index 1743ac475..32b51c164 100644 --- a/tests/test_emodel.py +++ b/tests/test_emodel.py @@ -597,16 +597,16 @@ def ids(params): # default read: derivation lists are not loaded, serialize as null plain = assert_request(client.get, url=f"{ROUTE}/{generated_id}").json() - assert plain["generated_derivations"] is None - assert plain["used_derivations"] is None + assert plain["generated_from_derivations"] is None + assert plain["used_by_derivations"] is None # expand on read_one, per direction expanded = assert_request( - client.get, url=f"{ROUTE}/{generated_id}", params={"expand": "generated_derivations"} + client.get, url=f"{ROUTE}/{generated_id}", params={"expand": "generated_from_derivations"} ).json() - assert expanded["used_derivations"] is None - assert len(expanded["generated_derivations"]) == 1 - entry = expanded["generated_derivations"][0] + assert expanded["used_by_derivations"] is None + assert len(expanded["generated_from_derivations"]) == 1 + entry = expanded["generated_from_derivations"][0] assert entry["used"]["id"] == used_id assert entry["used"]["type"] == EntityType.emodel assert entry["derivation_type"] == DerivationType.circuit_extraction @@ -616,10 +616,10 @@ def ids(params): src = next( d for d in assert_request( - client.get, url=ROUTE, params={"expand": "used_derivations"} + client.get, url=ROUTE, params={"expand": "used_by_derivations"} ).json()["data"] if d["id"] == used_id ) - assert src["generated_derivations"] is None - assert len(src["used_derivations"]) == 1 - assert src["used_derivations"][0]["generated"]["id"] == generated_id + assert src["generated_from_derivations"] is None + assert len(src["used_by_derivations"]) == 1 + assert src["used_by_derivations"][0]["generated"]["id"] == generated_id diff --git a/tests/test_ion_channel_recording.py b/tests/test_ion_channel_recording.py index 4b1ce75c2..f7dcc9fbd 100644 --- a/tests/test_ion_channel_recording.py +++ b/tests/test_ion_channel_recording.py @@ -57,8 +57,8 @@ def _assert_read_response(actual, expected): "update_date": ANY, "updated_by": ANY, "lifecycle_status": "active", - "generated_derivations": None, - "used_derivations": None, + "generated_from_derivations": None, + "used_by_derivations": None, } | { k: v for k, v in expected.items() From 2013f53783a57ce82ed1bb859cc207d517dbd084 Mon Sep 17 00:00:00 2001 From: Pavlo Getta Date: Tue, 30 Jun 2026 10:16:45 +0200 Subject: [PATCH 5/6] Revert _is_entity_model param type from object back to Any Per review feedback: query_params_factory's db_model_class is typed Any, so narrowing only the helper's signature is inconsistent and adds no real safety in this PR. --- app/queries/factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/queries/factory.py b/app/queries/factory.py index b171a5609..3635a11f3 100644 --- a/app/queries/factory.py +++ b/app/queries/factory.py @@ -48,7 +48,7 @@ from app.queries.types import ApplyOperations -def _is_entity_model(db_model_class: object) -> bool: +def _is_entity_model(db_model_class: Any) -> bool: """Whether the model is an Entity subclass (kept separate to avoid narrowing the caller).""" return isinstance(db_model_class, type) and issubclass(db_model_class, Entity) From 0538fb21e2870e8f6c373e291766b8967f6d6ee1 Mon Sep 17 00:00:00 2001 From: Pavlo Getta Date: Tue, 30 Jun 2026 10:29:32 +0200 Subject: [PATCH 6/6] Test EMCellMesh admin_read_one to restore PR patch coverage The admin read-one endpoint gained expand handling in this PR but had no test, leaving its response-schema selection and load options uncovered. Add a test hitting GET /admin/em-cell-mesh/{id} plain and with expand=measurement_annotation, covering both response-schema branches. --- tests/test_em_cell_mesh.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_em_cell_mesh.py b/tests/test_em_cell_mesh.py index a8cb29c78..aac85f340 100644 --- a/tests/test_em_cell_mesh.py +++ b/tests/test_em_cell_mesh.py @@ -80,6 +80,17 @@ def test_read_one(client, model, json_data): assert data["measurement_annotation"] is None +def test_admin_read_one(clients, model, json_data): + data = assert_request(clients.admin.get, url=f"{ADMIN_ROUTE}/{model.id}").json() + _assert_read_response(data, json_data) + + params = {"expand": "measurement_annotation"} + data = assert_request(clients.admin.get, url=f"{ADMIN_ROUTE}/{model.id}", params=params).json() + _assert_read_response(data, json_data) + assert "measurement_annotation" in data + assert data["measurement_annotation"] is None + + def test_missing(client): check_missing(ROUTE, client)