Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Default migration message

Revision ID: 8f0d631d2bd4
Revises: 1589bff44728
Create Date: 2025-06-02 14:51:26.859717

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


from sqlalchemy import Text
import app.db.types

# revision identifiers, used by Alembic.
revision: str = "8f0d631d2bd4"
down_revision: Union[str, None] = "1589bff44728"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"derivation",
sa.Column("used_id", sa.Uuid(), nullable=False),
sa.Column("generated_id", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(
["generated_id"], ["entity.id"], name=op.f("fk_derivation_generated_id_entity")
),
sa.ForeignKeyConstraint(
["used_id"], ["entity.id"], name=op.f("fk_derivation_used_id_entity")
),
sa.PrimaryKeyConstraint("used_id", "generated_id", name=op.f("pk_derivation")),
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("derivation")
# ### end Alembic commands ###
78 changes: 72 additions & 6 deletions app/cli/import_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from abc import ABC, abstractmethod
from collections import Counter, defaultdict
from contextlib import closing
from operator import attrgetter
from pathlib import Path
from typing import Any

Expand All @@ -18,7 +17,7 @@

from app.cli import curate, utils
from app.cli.brain_region_data import BRAIN_ATLAS_REGION_VOLUMES
from app.cli.curation import electrical_cell_recording
from app.cli.curation import cell_composition, electrical_cell_recording
from app.cli.types import ContentType
from app.cli.utils import (
AUTHORIZED_PUBLIC,
Expand All @@ -37,6 +36,7 @@
BrainRegionHierarchy,
CellComposition,
DataMaturityAnnotationBody,
Derivation,
ElectricalCellRecording,
EModel,
Entity,
Expand Down Expand Up @@ -71,10 +71,6 @@
from app.logger import L
from app.schemas.base import ProjectContext

from app.cli.curation import electrical_cell_recording, cell_composition
from app.cli.types import ContentType


BRAIN_ATLAS_NAME = "BlueBrain Atlas"

REQUIRED_PATH = click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True)
Expand Down Expand Up @@ -561,6 +557,75 @@ def ingest(
db.commit()


class ImportEModelDerivations(Import):
name = "EModelWorkflow"

@staticmethod
def is_correct_type(data):
types = ensurelist(data["@type"])
return "EModelWorkflow" in types

@staticmethod
def ingest(
db,
project_context,
data_list: list[dict],
all_data_by_id: dict[str, dict],
hierarchy_name: str,
):
"""Import emodel derivations from EModelWorkflow."""
legacy_emodel_ids = set()
derivations = {}
for data in tqdm(data_list, desc="EModelWorkflow"):
legacy_emodel_id = utils.find_id_in_entity(data, "EModel", "generates")
legacy_etc_id = utils.find_id_in_entity(
data, "ExtractionTargetsConfiguration", "hasPart"
)
if not legacy_emodel_id:
L.warning("Not found EModel id in EModelWorkflow: {}", data["@id"])
continue
if not legacy_etc_id:
L.warning(
"Not found ExtractionTargetsConfiguration id in EModelWorkflow: {}", data["@id"]
)
continue
if not (etc := all_data_by_id.get(legacy_etc_id)):
L.warning("Not found ExtractionTargetsConfiguration with id {}", legacy_etc_id)
continue
if not (legacy_trace_ids := list(utils.find_ids_in_entity(etc, "Trace", "uses"))):
L.warning(
"Not found traces in ExtractionTargetsConfiguration with id {}", legacy_etc_id
)
continue
if legacy_emodel_id in legacy_emodel_ids:
L.warning("Duplicated and ignored traces for EModel id {}", legacy_emodel_id)
continue
legacy_emodel_ids.add(legacy_emodel_id)
if not (emodel := utils._find_by_legacy_id(legacy_emodel_id, EModel, db)):
L.warning("Not found EModel with legacy id {}", legacy_emodel_id)
continue
if emodel.id in derivations:
L.warning("Duplicated and ignored traces for EModel uuid {}", emodel.id)
derivations[emodel.id] = [
utils._find_by_legacy_id(legacy_trace_id, ElectricalCellRecording, db).id
for legacy_trace_id in legacy_trace_ids
]

rows = [
Derivation(used_id=trace_id, generated_id=emodel_id)
for emodel_id, trace_ids in derivations.items()
for trace_id in trace_ids
]
L.info(
"Imported derivations for {} EModels from {} records", len(derivations), len(data_list)
)
# delete everything from derivation table before adding the records
query = sa.delete(Derivation)
db.execute(query)
db.add_all(rows)
db.commit()


class ImportBrainAtlas(Import):
name = "BrainAtlas"

Expand Down Expand Up @@ -1370,6 +1435,7 @@ def _do_import(db, input_dir, project_context, hierarchy_name):
ImportBrainAtlas,
ImportDistribution,
ImportNeuronMorphologyFeatureAnnotation,
ImportEModelDerivations,
]

for importer in importers:
Expand Down
15 changes: 8 additions & 7 deletions app/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,13 +361,14 @@ def get_or_create_distribution(
def find_id_in_entity(entity: dict | None, type_: str, entity_list_key: str):
if not entity:
return None
return next(
(
part.get("@id")
for part in ensurelist(entity.get(entity_list_key, []))
if is_type(part, type_)
),
None,
return next(find_ids_in_entity(entity, type_, entity_list_key), None)


def find_ids_in_entity(entity: dict, type_: str, entity_list_key: str):
return (
part.get("@id")
for part in ensurelist(entity.get(entity_list_key, []))
if is_type(part, type_)
)


Expand Down
8 changes: 8 additions & 0 deletions app/db/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -919,3 +919,11 @@ class CellComposition(NameDescriptionVectorMixin, LocationMixin, SpeciesMixin, E
__tablename__ = EntityType.cell_composition
id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), primary_key=True)
__mapper_args__ = {"polymorphic_identity": __tablename__} # noqa: RUF012


class Derivation(Base):
__tablename__ = "derivation"
used_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), primary_key=True)
generated_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), primary_key=True)
used: Mapped["Entity"] = relationship(foreign_keys=[used_id])
generated: Mapped["Entity"] = relationship(foreign_keys=[generated_id])
20 changes: 20 additions & 0 deletions app/filters/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import Entity
from app.db.types import EntityType
from app.filters.base import CustomFilter


class BasicEntityFilter(CustomFilter):
type: EntityType | None = None

order_by: list[str] = ["-creation_date"] # noqa: RUF012

class Constants(CustomFilter.Constants):
model = Entity
ordering_model_fields = ["creation_date", "update_date", "name"] # noqa: RUF012


BasicEntityFilterDep = Annotated[BasicEntityFilter, FilterDepends(BasicEntityFilter)]
2 changes: 2 additions & 0 deletions app/routers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
brain_region_hierarchy,
cell_composition,
contribution,
derivation,
electrical_cell_recording,
emodel,
etype,
Expand Down Expand Up @@ -45,6 +46,7 @@
brain_region_hierarchy.router,
cell_composition.router,
contribution.router,
derivation.router,
electrical_cell_recording.router,
emodel.router,
etype.router,
Expand Down
28 changes: 8 additions & 20 deletions app/routers/asset.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
"""Generic asset routes."""

import uuid
from enum import StrEnum
from http import HTTPStatus
from pathlib import Path
from typing import TYPE_CHECKING, Annotated
from typing import Annotated

from fastapi import APIRouter, Form, HTTPException, UploadFile, status
from starlette.responses import RedirectResponse

from app.config import settings
from app.db.types import AssetLabel, EntityType
from app.db.types import AssetLabel
from app.dependencies.auth import UserContextDep, UserContextWithProjectIdDep
from app.dependencies.db import RepoGroupDep
from app.dependencies.s3 import S3ClientDep
Expand All @@ -19,6 +18,7 @@
from app.schemas.types import ListResponse, PaginationResponse
from app.service import asset as asset_service
from app.utils.files import calculate_sha256_digest, get_content_type
from app.utils.routers import EntityRoute, entity_route_to_type
from app.utils.s3 import (
delete_from_s3,
generate_presigned_url,
Expand All @@ -32,18 +32,6 @@
tags=["assets"],
)

if not TYPE_CHECKING:
# EntityRoute (hyphen-separated) <-> EntityType (underscore_separated)
EntityRoute = StrEnum(
"EntityRoute", {item.name: item.name.replace("_", "-") for item in EntityType}
)
else:
EntityRoute = StrEnum


def _entity_route_to_type(entity_route: EntityRoute) -> EntityType:
return EntityType[entity_route.name]


@router.get("/{entity_route}/{entity_id}/assets")
def get_entity_assets(
Expand All @@ -56,7 +44,7 @@ def get_entity_assets(
assets = asset_service.get_entity_assets(
repos,
user_context=user_context,
entity_type=_entity_route_to_type(entity_route),
entity_type=entity_route_to_type(entity_route),
entity_id=entity_id,
)
# TODO: proper pagination
Expand All @@ -76,7 +64,7 @@ def get_entity_asset(
return asset_service.get_entity_asset(
repos,
user_context=user_context,
entity_type=_entity_route_to_type(entity_route),
entity_type=entity_route_to_type(entity_route),
entity_id=entity_id,
asset_id=asset_id,
)
Expand Down Expand Up @@ -117,7 +105,7 @@ def upload_entity_asset(
asset_read = asset_service.create_entity_asset(
repos=repos,
user_context=user_context,
entity_type=_entity_route_to_type(entity_route),
entity_type=entity_route_to_type(entity_route),
entity_id=entity_id,
filename=file.filename,
content_type=content_type,
Expand Down Expand Up @@ -149,7 +137,7 @@ def download_entity_asset(
asset = asset_service.get_entity_asset(
repos,
user_context=user_context,
entity_type=_entity_route_to_type(entity_route),
entity_type=entity_route_to_type(entity_route),
entity_id=entity_id,
asset_id=asset_id,
)
Expand Down Expand Up @@ -205,7 +193,7 @@ def delete_entity_asset(
asset = asset_service.delete_entity_asset(
repos,
user_context=user_context,
entity_type=_entity_route_to_type(entity_route),
entity_type=entity_route_to_type(entity_route),
entity_id=entity_id,
asset_id=asset_id,
)
Expand Down
12 changes: 12 additions & 0 deletions app/routers/derivation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Generic derivation routes."""

from fastapi import APIRouter

import app.service.derivation

router = APIRouter(
prefix="",
tags=["derivation"],
)

router.get("/{entity_route}/{entity_id}/derived-from")(app.service.derivation.read_many)
1 change: 1 addition & 0 deletions app/routers/emodel.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fastapi import APIRouter

import app.service.electrical_cell_recording
import app.service.emodel

router = APIRouter(
Expand Down
4 changes: 4 additions & 0 deletions app/schemas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,7 @@ class LicensedCreateMixin(BaseModel):
class LicensedReadMixin(BaseModel):
model_config = ConfigDict(from_attributes=True)
license: LicenseRead | None


class BasicEntityRead(IdentifiableMixin, EntityTypeMixin):
pass
Loading