Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions alembic/versions/0003_add_tol_id_to_assembly.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Add tol_id to assembly.

Revision ID: 0003_add_tol_id_to_assembly
Revises: 0002_timestamptz_broker_idx
Create Date: 2026-02-24 00:00:00.000000
"""

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = "0003_add_tol_id_to_assembly"
down_revision = "0002_timestamptz_broker_idx"
branch_labels = None
depends_on = None


def upgrade():
op.add_column("assembly", sa.Column("tol_id", sa.Text(), nullable=True))


def downgrade():
op.drop_column("assembly", "tol_id")
36 changes: 36 additions & 0 deletions alembic/versions/0004_add_assembly_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Add assembly_run table.

Revision ID: 0004_add_assembly_run
Revises: 0003_add_tol_id_to_assembly
Create Date: 2026-02-24 00:00:00.000000
"""

from alembic import op

# revision identifiers, used by Alembic.
revision = "0004_add_assembly_run"
down_revision = "0003_add_tol_id_to_assembly"
branch_labels = None
depends_on = None


def upgrade():
op.execute(
"""
CREATE TABLE IF NOT EXISTS assembly_run (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organism_key TEXT REFERENCES organism(grouping_key) NOT NULL,
sample_id UUID REFERENCES sample(id) NOT NULL,
data_types assembly_data_types NOT NULL,
version INTEGER NOT NULL,
tol_id TEXT,
status TEXT NOT NULL DEFAULT 'reserved',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"""
)


def downgrade():
op.execute("DROP TABLE IF EXISTS assembly_run;")
149 changes: 117 additions & 32 deletions app/api/v1/endpoints/assemblies.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
from sqlalchemy.orm import Session

from app.core.dependencies import get_current_active_user, get_db
from app.core.errors import AppError
from app.core.pagination import Pagination, apply_pagination, pagination_params
from app.core.policy import policy
from app.models.assembly import Assembly, AssemblyFile, AssemblySubmission
from app.models.assembly import Assembly, AssemblyFile, AssemblyRun, AssemblySubmission
from app.models.experiment import Experiment
from app.models.organism import Organism
from app.models.read import Read
Expand All @@ -21,6 +22,7 @@
AssemblyCreateFromExperiments,
AssemblyFileCreate,
AssemblyFileUpdate,
AssemblyIntent,
AssemblySubmissionCreate,
AssemblySubmissionUpdate,
AssemblyUpdate,
Expand All @@ -32,7 +34,7 @@
AssemblySubmission as AssemblySubmissionSchema,
)
from app.schemas.common import SubmissionStatus
from app.services.assembly_helper import generate_assembly_manifest
from app.services.assembly_helper import determine_assembly_data_types, generate_assembly_manifest
from app.services.assembly_service import (
assembly_file_service,
assembly_service,
Expand Down Expand Up @@ -205,15 +207,52 @@ def get_pipeline_inputs_by_tax_id(
return result


def _get_manifest_inputs_by_tax_id(db: Session, tax_id: int, sample_id: UUID):
organism = db.query(Organism).filter(Organism.tax_id == tax_id).first()
if not organism:
raise HTTPException(status_code=404, detail=f"Organism with tax_id {tax_id} not found")

sample = db.query(Sample).filter(Sample.id == sample_id).first()
# if not sample or sample.organism_key != organism.grouping_key:
if not sample:
raise HTTPException(status_code=404, detail="Sample not found for this organism")

experiments = db.query(Experiment).filter(Experiment.sample_id == sample_id).all()
if not experiments:
raise HTTPException(
status_code=404,
detail=f"No experiments found for organism {organism.grouping_key} and sample {sample_id} (tax_id: {tax_id})",
)

experiment_ids = [exp.id for exp in experiments]
reads = db.query(Read).filter(Read.experiment_id.in_(experiment_ids)).all()
if not reads:
raise HTTPException(
status_code=404,
detail=f"No reads found for organism {organism.grouping_key} (tax_id: {tax_id})",
)

return organism, reads, experiments


def _get_optimal_sample_id_for_tax_id(db: Session, tax_id: int) -> UUID | None:
# TODO: Implement specimen/long-read selection logic.
# Placeholder for now; return None to indicate no automatic selection.
_ = db, tax_id
return None


@router.get("/manifest/{tax_id}")
def get_assembly_manifest(
*,
db: Session = Depends(get_db),
tax_id: int,
sample_id: UUID = Query(..., description="Sample ID for the manifest"),
version: Optional[int] = Query(None, description="Reserved manifest version to retrieve"),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Generate assembly manifest YAML for an organism by tax_id.
Retrieve the latest reserved assembly manifest YAML for an organism by tax_id.

Returns YAML manifest with:
- scientific_name and taxon_id from organism
Expand All @@ -226,46 +265,92 @@ def get_assembly_manifest(
"""
from fastapi.responses import Response

# Get organism by tax_id
organism = db.query(Organism).filter(Organism.tax_id == tax_id).first()
if not organism:
raise HTTPException(status_code=404, detail=f"Organism with tax_id {tax_id} not found")
organism, reads, experiments = _get_manifest_inputs_by_tax_id(db, tax_id, sample_id)

# Get all samples for this organism
samples = db.query(Sample).filter(Sample.organism_key == organism.grouping_key).all()
if not samples:
raise HTTPException(
status_code=404,
detail=f"No samples found for organism {organism.grouping_key} (tax_id: {tax_id})",
run_query = (
db.query(AssemblyRun)
.filter(
AssemblyRun.organism_key == organism.grouping_key,
AssemblyRun.sample_id == sample_id,
)
.order_by(AssemblyRun.created_at.desc())
)
if version is not None:
run_query = run_query.filter(AssemblyRun.version == version)
assembly_run = run_query.first()
if not assembly_run:
raise HTTPException(status_code=404, detail="No reserved assembly manifest found")

yaml_content = generate_assembly_manifest(
organism, reads, experiments, assembly_run.tol_id, assembly_run.version
)

# Get all experiments for these samples
sample_ids = [sample.id for sample in samples]
experiments = db.query(Experiment).filter(Experiment.sample_id.in_(sample_ids)).all()
# Return as YAML response
return Response(content=yaml_content, media_type="application/x-yaml")

if not experiments:
raise HTTPException(
status_code=404,
detail=f"No experiments found for organism {organism.grouping_key} (tax_id: {tax_id})",
)

# Get all reads for these experiments
experiment_ids = [exp.id for exp in experiments]
reads = db.query(Read).filter(Read.experiment_id.in_(experiment_ids)).all()
@router.post("/intent/{tax_id}")
@policy("assemblies:write")
def create_assembly_intent(
*,
db: Session = Depends(get_db),
tax_id: int,
intent_in: AssemblyIntent,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Reserve the next assembly version and return a manifest.
"""
from fastapi.responses import Response

if not reads:
raise HTTPException(
status_code=404,
detail=f"No reads found for organism {organism.grouping_key} (tax_id: {tax_id})",
)
organism, reads, experiments = _get_manifest_inputs_by_tax_id(db, tax_id, intent_in.sample_id)
try:
data_types = determine_assembly_data_types(experiments)
except ValueError as exc:
raise AppError(
status_code=400,
code="assembly_intent_invalid_data_types",
message=str(exc),
details={
"tax_id": tax_id,
"sample_id": str(intent_in.sample_id),
},
) from exc

next_version = assembly_service.get_next_version(
db,
organism_key=organism.grouping_key,
sample_id=intent_in.sample_id,
data_types=data_types,
)

# Generate YAML manifest
yaml_content = generate_assembly_manifest(organism, reads, experiments)
run = AssemblyRun(
organism_key=organism.grouping_key,
sample_id=intent_in.sample_id,
data_types=data_types,
version=next_version,
tol_id=intent_in.tol_id,
status="reserved",
)
db.add(run)
db.commit()
db.refresh(run)

# Return as YAML response
yaml_content = generate_assembly_manifest(organism, reads, experiments, run.tol_id, run.version)
return Response(content=yaml_content, media_type="application/x-yaml")


@router.get("/optimal-sample/{tax_id}")
def get_optimal_sample_id(
*,
db: Session = Depends(get_db),
tax_id: int,
current_user: User = Depends(get_current_active_user),
) -> Any:
sample_id = _get_optimal_sample_id_for_tax_id(db, tax_id)
return {"sample_id": str(sample_id) if sample_id else None}


@router.post("/from-experiments/{tax_id}", response_model=AssemblySchema)
@policy("assemblies:write")
def create_assembly_from_experiments(
Expand Down
41 changes: 41 additions & 0 deletions app/models/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class Assembly(Base):
# Assembly metadata fields
assembly_name = Column(Text, nullable=False)
assembly_type = Column(Text, nullable=False, default="clone or isolate")
tol_id = Column(Text, nullable=True)
data_types = Column(
SQLAlchemyEnum(
"PACBIO_SMRT",
Expand Down Expand Up @@ -72,6 +73,46 @@ class Assembly(Base):
project = relationship("Project", backref="assemblies")


class AssemblyRun(Base):
"""
AssemblyRun model for reserving versions and tracking assembly intents.

This model corresponds to the 'assembly_run' table in the database.
"""

__tablename__ = "assembly_run"

id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
organism_key = Column("organism_key", ForeignKey("organism.grouping_key"), nullable=False)
sample_id = Column(UUID(as_uuid=True), ForeignKey("sample.id"), nullable=False)
data_types = Column(
SQLAlchemyEnum(
"PACBIO_SMRT",
"PACBIO_SMRT_HIC",
"OXFORD_NANOPORE",
"OXFORD_NANOPORE_HIC",
"PACBIO_SMRT_OXFORD_NANOPORE",
"PACBIO_SMRT_OXFORD_NANOPORE_HIC",
name="assembly_data_types",
),
nullable=False,
)
version = Column(Integer, nullable=False)
tol_id = Column(Text, nullable=True)
status = Column(Text, nullable=False, default="reserved")

created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)

organism = relationship("Organism", backref="assembly_runs")
sample = relationship("Sample", backref="assembly_runs")


class AssemblySubmission(Base):
"""
AssemblySubmission model for storing assembly submission data to ENA.
Expand Down
10 changes: 10 additions & 0 deletions app/schemas/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class AssemblyBase(BaseModel):
project_id: Optional[UUID] = None
assembly_name: str
assembly_type: str = "clone or isolate"
tol_id: str
data_types: AssemblyDataTypes
coverage: float
program: str
Expand All @@ -61,6 +62,7 @@ class AssemblyCreateFromExperiments(BaseModel):
project_id: Optional[UUID] = None
assembly_name: str
assembly_type: str = "clone or isolate"
tol_id: str
data_types: Optional[AssemblyDataTypes] = None # Auto-detected, can be overridden
coverage: float
program: str
Expand All @@ -69,6 +71,13 @@ class AssemblyCreateFromExperiments(BaseModel):
description: Optional[str] = None


class AssemblyIntent(BaseModel):
"""Schema for reserving an assembly version and generating a manifest."""

sample_id: UUID
tol_id: Optional[str] = None


# Schema for updating an existing assembly
class AssemblyUpdate(BaseModel):
"""Schema for updating an existing assembly."""
Expand All @@ -78,6 +87,7 @@ class AssemblyUpdate(BaseModel):
project_id: Optional[UUID] = None
assembly_name: Optional[str] = None
assembly_type: Optional[str] = None
tol_id: Optional[str] = None
coverage: Optional[float] = None
program: Optional[str] = None
mingaplength: Optional[float] = None
Expand Down
Loading
Loading