diff --git a/.gitignore b/.gitignore index 50c10b5..0cffff1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ postman/ .coverage htmlcov/ coverage.xml +.claude/ +.windsurf/ diff --git a/alembic/versions/0010_add_taxonomy_info.py b/alembic/versions/0010_add_taxonomy_info.py new file mode 100644 index 0000000..fc629f0 --- /dev/null +++ b/alembic/versions/0010_add_taxonomy_info.py @@ -0,0 +1,63 @@ +"""Add taxonomy_info table and migrate augustus_dataset_name off organism. + +Revision ID: 0010_add_taxonomy_info +Revises: 0009_use_taxon_id_as_organism_pk +Create Date: 2026-05-01 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0010_add_taxonomy_info" +down_revision = "0009_use_taxon_id_as_organism_pk" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Create taxonomy_info table with PK = FK to organism.taxon_id + op.create_table( + "taxonomy_info", + sa.Column("taxon_id", sa.Integer(), nullable=False), + sa.Column("busco_odb10_dataset_name", sa.Text(), nullable=True), + sa.Column("busco_odb12_dataset_name", sa.Text(), nullable=True), + sa.Column("find_plastid", sa.Boolean(), nullable=True), + sa.Column("hic_motif", sa.Text(), nullable=True), + sa.Column("mitochondrial_genetic_code_id", sa.Integer(), nullable=True), + sa.Column("mitohifi_reference_species", sa.Text(), nullable=True), + sa.Column("oatk_hmm_name", sa.Text(), nullable=True), + sa.Column("defined_class", sa.Text(), nullable=True), + sa.Column("augustus_dataset_name", sa.Text(), nullable=True), + sa.Column("genetic_code_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("taxon_id"), + sa.ForeignKeyConstraint( + ["taxon_id"], ["organism.taxon_id"], ondelete="CASCADE" + ), + ) + + # 2. Migrate existing augustus_dataset_name values from organism into taxonomy_info + op.execute(""" + INSERT INTO taxonomy_info (taxon_id, augustus_dataset_name) + SELECT taxon_id, augustus_dataset_name + FROM organism + WHERE augustus_dataset_name IS NOT NULL + """) + + # 3. Drop augustus_dataset_name from organism + op.drop_column("organism", "augustus_dataset_name") + + +def downgrade() -> None: + # 1. Restore the column on organism + op.add_column("organism", sa.Column("augustus_dataset_name", sa.Text(), nullable=True)) + + # 2. Copy data back from taxonomy_info + op.execute(""" + UPDATE organism o + SET augustus_dataset_name = ti.augustus_dataset_name + FROM taxonomy_info ti + WHERE o.taxon_id = ti.taxon_id + """) + + # 3. Drop the taxonomy_info table + op.drop_table("taxonomy_info") diff --git a/app/api/v1/api.py b/app/api/v1/api.py index 446011c..60377c7 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -15,6 +15,7 @@ reads, sample_submissions, samples, + taxonomy_info, users, xml_export, ) @@ -46,6 +47,9 @@ api_router.include_router(reads.router, prefix="/reads", tags=["reads"]) api_router.include_router(qc_reads.router, prefix="/qc-reads", tags=["qc-reads"]) api_router.include_router(genome_notes.router, prefix="/genome-notes", tags=["genome-notes"]) +api_router.include_router( + taxonomy_info.router, prefix="/taxonomy-info", tags=["taxonomy-info"] +) # XML export endpoints api_router.include_router(xml_export.router, prefix="/xml-export", tags=["xml-export"]) diff --git a/app/api/v1/endpoints/taxonomy_info.py b/app/api/v1/endpoints/taxonomy_info.py new file mode 100644 index 0000000..68fe4b2 --- /dev/null +++ b/app/api/v1/endpoints/taxonomy_info.py @@ -0,0 +1,110 @@ +from typing import Any, Dict, List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.dependencies import get_current_active_user, get_db +from app.core.pagination import Pagination, apply_pagination, pagination_params +from app.core.policy import policy +from app.models.taxonomy_info import TaxonomyInfo +from app.models.user import User +from app.schemas.bulk_import import BulkImportResponse +from app.schemas.taxonomy_info import ( + TaxonomyInfo as TaxonomyInfoSchema, +) +from app.schemas.taxonomy_info import ( + TaxonomyInfoCreate, + TaxonomyInfoUpdate, +) +from app.services.taxonomy_info_service import taxonomy_info_service + +router = APIRouter() + + +@router.get("/", response_model=List[TaxonomyInfoSchema]) +def list_taxonomy_info( + db: Session = Depends(get_db), + pagination: Pagination = Depends(pagination_params), + current_user: User = Depends(get_current_active_user), +) -> Any: + """List taxonomy info records.""" + query = db.query(TaxonomyInfo) + query = apply_pagination(query, pagination) + return query.all() + + +@router.post("/bulk-import", response_model=BulkImportResponse) +@policy("taxonomy_info:bulk_import") +def bulk_import_taxonomy_info( + *, + db: Session = Depends(get_db), + data: Dict[str, Dict[str, Any]], + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Bulk import taxonomy info from a dictionary keyed by taxon_id. + + Insert-only — existing rows are skipped and reported as errors. + The taxon_id key must reference an existing organism. + """ + return taxonomy_info_service.bulk_import(db, data=data) + + +@router.get("/{taxon_id}", response_model=TaxonomyInfoSchema) +def get_taxonomy_info( + *, + db: Session = Depends(get_db), + taxon_id: int, + current_user: User = Depends(get_current_active_user), +) -> Any: + """Get taxonomy info by taxon_id.""" + ti = taxonomy_info_service.get(db, taxon_id) + if not ti: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TaxonomyInfo not found") + return ti + + +@router.post("/", response_model=TaxonomyInfoSchema, status_code=status.HTTP_201_CREATED) +@policy("taxonomy_info:create") +def create_taxonomy_info( + *, + db: Session = Depends(get_db), + ti_in: TaxonomyInfoCreate, + current_user: User = Depends(get_current_active_user), +) -> Any: + """Create taxonomy info for an existing organism.""" + try: + return taxonomy_info_service.create(db, ti_in=ti_in) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + + +@router.patch("/{taxon_id}", response_model=TaxonomyInfoSchema) +@policy("taxonomy_info:update") +def update_taxonomy_info( + *, + db: Session = Depends(get_db), + taxon_id: int, + ti_in: TaxonomyInfoUpdate, + current_user: User = Depends(get_current_active_user), +) -> Any: + """Update taxonomy info by taxon_id.""" + ti = taxonomy_info_service.update(db, taxon_id=taxon_id, ti_in=ti_in) + if not ti: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TaxonomyInfo not found") + return ti + + +@router.delete("/{taxon_id}", response_model=TaxonomyInfoSchema) +@policy("taxonomy_info:delete") +def delete_taxonomy_info( + *, + db: Session = Depends(get_db), + taxon_id: int, + current_user: User = Depends(get_current_active_user), +) -> Any: + """Delete taxonomy info by taxon_id.""" + ti = taxonomy_info_service.delete(db, taxon_id=taxon_id) + if not ti: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TaxonomyInfo not found") + return ti diff --git a/app/core/policy.py b/app/core/policy.py index 131094c..1f297ed 100644 --- a/app/core/policy.py +++ b/app/core/policy.py @@ -59,6 +59,11 @@ "admin:expire_leases": ["admin", "superuser"], # Broker "broker:claim": ["broker"], + # Taxonomy info + "taxonomy_info:create": ["curator", "admin"], + "taxonomy_info:update": ["curator", "admin"], + "taxonomy_info:delete": ["admin", "superuser"], + "taxonomy_info:bulk_import": ["curator", "admin"], } diff --git a/app/models/__init__.py b/app/models/__init__.py index 93dad50..558d4ad 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.experiment import Experiment, ExperimentSubmission from app.models.genome_note import GenomeNote from app.models.organism import Organism +from app.models.taxonomy_info import TaxonomyInfo from app.models.project import Project from app.models.qc_read import QcRead, QcReadFile, QcReadSubmission from app.models.read import Read diff --git a/app/models/organism.py b/app/models/organism.py index 90c56cc..6eda0f9 100644 --- a/app/models/organism.py +++ b/app/models/organism.py @@ -1,7 +1,6 @@ -import uuid - from sqlalchemy import Column, DateTime, Integer, Text, func -from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship from app.db.session import Base @@ -29,7 +28,6 @@ class Organism(Base): ncbi_order = Column(Text, nullable=True) ncbi_family = Column(Text, nullable=True) busco_dataset_name = Column(Text, nullable=True) - augustus_dataset_name = Column(Text, nullable=True) bpa_json = Column(JSONB, nullable=True) taxonomy_lineage_json = Column(JSONB, nullable=True) created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) @@ -39,3 +37,10 @@ class Organism(Base): server_default=func.now(), onupdate=func.now(), ) + + taxonomy_info = relationship( + "TaxonomyInfo", + back_populates="organism", + uselist=False, + cascade="all, delete-orphan", + ) diff --git a/app/models/taxonomy_info.py b/app/models/taxonomy_info.py new file mode 100644 index 0000000..0b35d82 --- /dev/null +++ b/app/models/taxonomy_info.py @@ -0,0 +1,24 @@ +from sqlalchemy import Boolean, Column, ForeignKey, Integer, Text +from sqlalchemy.orm import relationship + +from app.db.session import Base + + +class TaxonomyInfo(Base): + __tablename__ = "taxonomy_info" + + taxon_id = Column( + Integer, ForeignKey("organism.taxon_id", ondelete="CASCADE"), primary_key=True + ) + busco_odb10_dataset_name = Column(Text, nullable=True) + busco_odb12_dataset_name = Column(Text, nullable=True) + find_plastid = Column(Boolean, nullable=True) + hic_motif = Column(Text, nullable=True) + mitochondrial_genetic_code_id = Column(Integer, nullable=True) + mitohifi_reference_species = Column(Text, nullable=True) + oatk_hmm_name = Column(Text, nullable=True) + defined_class = Column(Text, nullable=True) + augustus_dataset_name = Column(Text, nullable=True) + genetic_code_id = Column(Integer, nullable=True) + + organism = relationship("Organism", back_populates="taxonomy_info") diff --git a/app/schemas/organism.py b/app/schemas/organism.py index c6e9d2a..b1b90b2 100644 --- a/app/schemas/organism.py +++ b/app/schemas/organism.py @@ -1,15 +1,16 @@ +from __future__ import annotations + from datetime import datetime -from enum import Enum -from typing import Dict, Optional -from uuid import UUID +from typing import TYPE_CHECKING, Dict, Optional from pydantic import BaseModel, ConfigDict, model_validator -# Enum for submission status -from app.schemas.common import SubmissionStatus +from app.schemas.common import SubmissionStatus # noqa: F401 – kept for consumers + +if TYPE_CHECKING: + from app.schemas.taxonomy_info import TaxonomyInfo as TaxonomyInfoSchema -# Base Organism schema class OrganismBase(BaseModel): """Base Organism schema with common attributes.""" @@ -27,7 +28,6 @@ class OrganismBase(BaseModel): ncbi_order: Optional[str] = None ncbi_family: Optional[str] = None busco_dataset_name: Optional[str] = None - augustus_dataset_name: Optional[str] = None bpa_json: Optional[Dict] = None taxonomy_lineage_json: Optional[Dict] = None @@ -40,14 +40,12 @@ def _coerce_legacy_keys(cls, data): return data -# Schema for creating a new organism class OrganismCreate(OrganismBase): """Schema for creating a new organism.""" pass -# Schema for updating an existing organism class OrganismUpdate(BaseModel): """Schema for updating an existing organism.""" @@ -64,12 +62,10 @@ class OrganismUpdate(BaseModel): ncbi_order: Optional[str] = None ncbi_family: Optional[str] = None busco_dataset_name: Optional[str] = None - augustus_dataset_name: Optional[str] = None bpa_json: Optional[Dict] = None taxonomy_lineage_json: Optional[Dict] = None -# Schema for organism in DB class OrganismInDBBase(OrganismBase): """Base schema for Organism in DB, includes id and timestamps.""" @@ -79,8 +75,13 @@ class OrganismInDBBase(OrganismBase): model_config = ConfigDict(from_attributes=True, populate_by_name=True) -# Schema for returning organism information class Organism(OrganismInDBBase): """Schema for returning organism information.""" - pass + taxonomy_info: Optional[TaxonomyInfoSchema] = None + + +# Resolve forward references now that all schemas are defined. +from app.schemas.taxonomy_info import TaxonomyInfo as TaxonomyInfoSchema # noqa: E402 + +Organism.model_rebuild() diff --git a/app/schemas/taxonomy_info.py b/app/schemas/taxonomy_info.py new file mode 100644 index 0000000..f93743e --- /dev/null +++ b/app/schemas/taxonomy_info.py @@ -0,0 +1,34 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class TaxonomyInfoBase(BaseModel): + busco_odb10_dataset_name: Optional[str] = None + busco_odb12_dataset_name: Optional[str] = None + find_plastid: Optional[bool] = None + hic_motif: Optional[str] = None + mitochondrial_genetic_code_id: Optional[int] = None + mitohifi_reference_species: Optional[str] = None + oatk_hmm_name: Optional[str] = None + defined_class: Optional[str] = None + augustus_dataset_name: Optional[str] = None + genetic_code_id: Optional[int] = None + + +class TaxonomyInfoCreate(TaxonomyInfoBase): + taxon_id: int + + +class TaxonomyInfoUpdate(TaxonomyInfoBase): + pass + + +class TaxonomyInfoInDBBase(TaxonomyInfoBase): + taxon_id: int + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class TaxonomyInfo(TaxonomyInfoInDBBase): + pass diff --git a/app/services/assembly_helper.py b/app/services/assembly_helper.py index 83e360f..2d444d0 100644 --- a/app/services/assembly_helper.py +++ b/app/services/assembly_helper.py @@ -64,9 +64,10 @@ def determine_assembly_data_types(experiments: List[Experiment]) -> AssemblyData elif has_nanopore: return AssemblyDataTypes.OXFORD_NANOPORE else: + # TODO decide if we relax this requirement and still return the manifest noting the available data types are not supported raise ValueError( - "No valid sequencing platforms detected in experiments. " - "Expected PACBIO_SMRT, OXFORD_NANOPORE, or ILLUMINA with Hi-C library strategy." + "No valid data types detected in experiments. " + "Expected PACBIO_SMRT (with or without Hi-C), OXFORD_NANOPORE (with or without Hi-C), or ILLUMINA with Hi-C." ) diff --git a/app/services/organism_service.py b/app/services/organism_service.py index d7df20f..9160f84 100644 --- a/app/services/organism_service.py +++ b/app/services/organism_service.py @@ -182,7 +182,6 @@ def create_organism(self, db: Session, *, organism_in: OrganismCreate) -> Organi ncbi_order=organism_in.ncbi_order, ncbi_family=organism_in.ncbi_family, busco_dataset_name=organism_in.busco_dataset_name, - augustus_dataset_name=organism_in.augustus_dataset_name, taxonomy_lineage_json=organism_in.taxonomy_lineage_json, bpa_json=organism_in.model_dump(exclude_unset=True), ) @@ -275,7 +274,6 @@ def update_organism( "ncbi_order", "ncbi_family", "busco_dataset_name", - "augustus_dataset_name", "common_name", "common_name_source", "tax_string", @@ -347,7 +345,6 @@ def bulk_import_organisms( ncbi_order=organism_data.get("ncbi_order"), ncbi_family=organism_data.get("ncbi_family"), busco_dataset_name=organism_data.get("busco_dataset_name"), - augustus_dataset_name=organism_data.get("augustus_dataset_name"), bpa_json=organism_data, ) root_project = Project( diff --git a/app/services/taxonomy_info_service.py b/app/services/taxonomy_info_service.py new file mode 100644 index 0000000..bc1813a --- /dev/null +++ b/app/services/taxonomy_info_service.py @@ -0,0 +1,127 @@ +from typing import Any, Dict, List, Optional + +from sqlalchemy.orm import Session + +from app.models.organism import Organism +from app.models.taxonomy_info import TaxonomyInfo +from app.schemas.bulk_import import BulkImportResponse +from app.schemas.taxonomy_info import TaxonomyInfoCreate, TaxonomyInfoUpdate + + +class TaxonomyInfoService: + """Service for TaxonomyInfo CRUD and bulk import operations.""" + + def get(self, db: Session, taxon_id: int) -> Optional[TaxonomyInfo]: + return db.query(TaxonomyInfo).filter(TaxonomyInfo.taxon_id == taxon_id).first() + + def list(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[TaxonomyInfo]: + return db.query(TaxonomyInfo).offset(skip).limit(limit).all() + + def create(self, db: Session, *, ti_in: TaxonomyInfoCreate) -> TaxonomyInfo: + organism = db.query(Organism).filter(Organism.taxon_id == ti_in.taxon_id).first() + if not organism: + raise ValueError(f"Organism with taxon_id {ti_in.taxon_id} does not exist") + + existing = db.query(TaxonomyInfo).filter(TaxonomyInfo.taxon_id == ti_in.taxon_id).first() + if existing: + raise ValueError(f"TaxonomyInfo for taxon_id {ti_in.taxon_id} already exists") + + ti = TaxonomyInfo(**ti_in.model_dump()) + db.add(ti) + db.commit() + db.refresh(ti) + return ti + + def update( + self, db: Session, *, taxon_id: int, ti_in: TaxonomyInfoUpdate + ) -> Optional[TaxonomyInfo]: + ti = db.query(TaxonomyInfo).filter(TaxonomyInfo.taxon_id == taxon_id).first() + if not ti: + return None + for field, value in ti_in.model_dump(exclude_unset=True).items(): + setattr(ti, field, value) + db.add(ti) + db.commit() + db.refresh(ti) + return ti + + def delete(self, db: Session, *, taxon_id: int) -> Optional[TaxonomyInfo]: + ti = db.query(TaxonomyInfo).filter(TaxonomyInfo.taxon_id == taxon_id).first() + if not ti: + return None + db.delete(ti) + db.commit() + return ti + + def bulk_import( + self, db: Session, *, data: Dict[str, Dict[str, Any]] + ) -> BulkImportResponse: + created_count = 0 + skipped_count = 0 + errors: List[str] = [] + + for key, row in data.items(): + try: + # Resolve taxon_id from top-level key + try: + taxon_id = int(key) + except (ValueError, TypeError): + errors.append(f"{key}: top-level key is not a valid integer taxon_id") + skipped_count += 1 + continue + + # If inner object also supplies taxon_id it must match the key + inner_taxon_id = row.get("taxon_id") + if inner_taxon_id is not None and int(inner_taxon_id) != taxon_id: + errors.append( + f"{key}: inner taxon_id ({inner_taxon_id}) does not match top-level key" + ) + skipped_count += 1 + continue + + # Validate organism exists + organism = db.query(Organism).filter(Organism.taxon_id == taxon_id).first() + if not organism: + errors.append(f"{key}: organism with taxon_id {taxon_id} does not exist") + skipped_count += 1 + continue + + # Reject duplicates + existing = ( + db.query(TaxonomyInfo).filter(TaxonomyInfo.taxon_id == taxon_id).first() + ) + if existing: + errors.append(f"{key}: taxonomy_info for taxon_id {taxon_id} already exists") + skipped_count += 1 + continue + + ti = TaxonomyInfo( + taxon_id=taxon_id, + busco_odb10_dataset_name=row.get("busco_odb10_dataset_name"), + busco_odb12_dataset_name=row.get("busco_odb12_dataset_name"), + find_plastid=row.get("find_plastid"), + hic_motif=row.get("hic_motif"), + mitochondrial_genetic_code_id=row.get("mitochondrial_genetic_code_id"), + mitohifi_reference_species=row.get("mitohifi_reference_species"), + oatk_hmm_name=row.get("oatk_hmm_name"), + defined_class=row.get("defined_class"), + augustus_dataset_name=row.get("augustus_dataset_name"), + genetic_code_id=row.get("genetic_code_id"), + ) + db.add(ti) + db.commit() + created_count += 1 + except Exception as e: + errors.append(f"{key}: {str(e)}") + db.rollback() + skipped_count += 1 + + return BulkImportResponse( + created_count=created_count, + skipped_count=skipped_count, + message=f"TaxonomyInfo import complete. Created: {created_count}, Skipped: {skipped_count}", + errors=errors if errors else None, + ) + + +taxonomy_info_service = TaxonomyInfoService() diff --git a/tests/unit/endpoints/test_endpoints_assemblies.py b/tests/unit/endpoints/test_endpoints_assemblies.py index bba2e27..2f8b8b6 100644 --- a/tests/unit/endpoints/test_endpoints_assemblies.py +++ b/tests/unit/endpoints/test_endpoints_assemblies.py @@ -342,7 +342,7 @@ def test_create_assembly_intent_invalid_data_types_returns_app_error(monkeypatch assert resp.status_code == 400 body = resp.json() assert body["error"]["code"] == "assembly_intent_invalid_data_types" - assert "No valid sequencing platforms detected" in body["error"]["message"] + assert "No valid data types detected in experiments" in body["error"]["message"] def test_create_assembly_intent_allows_empty_body(monkeypatch): diff --git a/tests/unit/endpoints/test_endpoints_organisms.py b/tests/unit/endpoints/test_endpoints_organisms.py index 2865295..1613c24 100644 --- a/tests/unit/endpoints/test_endpoints_organisms.py +++ b/tests/unit/endpoints/test_endpoints_organisms.py @@ -63,9 +63,9 @@ def test_organisms_list_and_not_found(monkeypatch): "ncbi_order": None, "ncbi_family": None, "busco_dataset_name": None, - "augustus_dataset_name": None, "bpa_json": None, "taxonomy_lineage_json": None, + "taxonomy_info": None, "created_at": now, "updated_at": now, } diff --git a/tests/unit/endpoints/test_endpoints_taxonomy_info.py b/tests/unit/endpoints/test_endpoints_taxonomy_info.py new file mode 100644 index 0000000..7971f48 --- /dev/null +++ b/tests/unit/endpoints/test_endpoints_taxonomy_info.py @@ -0,0 +1,378 @@ +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient + +from app.api.v1.endpoints import taxonomy_info as ti_module +from app.main import app +from app.schemas.bulk_import import BulkImportResponse +from app.schemas.organism import Organism as OrganismSchema + + +def _override_user(): + return SimpleNamespace(is_superuser=False, roles=["curator"], is_active=True) + + +def _override_db(fake): + def _gen(): + yield fake + + return _gen + + +def _make_ti(taxon_id=1, **kwargs): + """Return a SimpleNamespace that looks like a TaxonomyInfo ORM object.""" + defaults = { + "taxon_id": taxon_id, + "busco_odb10_dataset_name": None, + "busco_odb12_dataset_name": None, + "find_plastid": None, + "hic_motif": None, + "mitochondrial_genetic_code_id": None, + "mitohifi_reference_species": None, + "oatk_hmm_name": None, + "defined_class": None, + "augustus_dataset_name": None, + "genetic_code_id": None, + } + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +class _FakeSession: + def query(self, *_): + return self + + def offset(self, *_): + return self + + def limit(self, *_): + return self + + def all(self): + return [] + + def filter(self, *_args, **_kwargs): + return self + + def first(self): + return None + + +@pytest.fixture(autouse=True) +def _clear_overrides(): + app.dependency_overrides = {} + yield + app.dependency_overrides = {} + + +# --------------------------------------------------------------------------- +# List +# --------------------------------------------------------------------------- + + +def test_list_taxonomy_info_empty(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(list=lambda db, skip=0, limit=100: []), + ) + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + resp = client.get("/api/v1/taxonomy-info/") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_list_taxonomy_info_returns_records(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + ti = _make_ti(taxon_id=5077, augustus_dataset_name="anidulans", genetic_code_id=1) + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + + class _FakeSessionWithRow(_FakeSession): + def all(self): + return [ti] + + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSessionWithRow()) + resp = client.get("/api/v1/taxonomy-info/") + assert resp.status_code == 200 + body = resp.json() + assert len(body) == 1 + assert body[0]["taxon_id"] == 5077 + assert body[0]["augustus_dataset_name"] == "anidulans" + + +# --------------------------------------------------------------------------- +# Get by taxon_id +# --------------------------------------------------------------------------- + + +def test_get_taxonomy_info_found(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + ti = _make_ti(taxon_id=5077) + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(get=lambda db, taxon_id: ti), + ) + resp = client.get("/api/v1/taxonomy-info/5077") + assert resp.status_code == 200 + assert resp.json()["taxon_id"] == 5077 + + +def test_get_taxonomy_info_not_found(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(get=lambda db, taxon_id: None), + ) + resp = client.get("/api/v1/taxonomy-info/9999") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Create +# --------------------------------------------------------------------------- + + +def test_create_taxonomy_info_success(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + ti = _make_ti(taxon_id=5077, augustus_dataset_name="anidulans") + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(create=lambda db, ti_in: ti), + ) + resp = client.post("/api/v1/taxonomy-info/", json={"taxon_id": 5077, "augustus_dataset_name": "anidulans"}) + assert resp.status_code == 201 + assert resp.json()["taxon_id"] == 5077 + + +def test_create_taxonomy_info_organism_missing(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + + def _raise(db, ti_in): + raise ValueError("Organism with taxon_id 9999 does not exist") + + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(create=_raise), + ) + resp = client.post("/api/v1/taxonomy-info/", json={"taxon_id": 9999}) + assert resp.status_code == 409 + assert "does not exist" in resp.json()["error"]["message"] + + +def test_create_taxonomy_info_duplicate(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + + def _raise(db, ti_in): + raise ValueError("TaxonomyInfo for taxon_id 5077 already exists") + + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(create=_raise), + ) + resp = client.post("/api/v1/taxonomy-info/", json={"taxon_id": 5077}) + assert resp.status_code == 409 + assert "already exists" in resp.json()["error"]["message"] + + +# --------------------------------------------------------------------------- +# Update +# --------------------------------------------------------------------------- + + +def test_update_taxonomy_info_success(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + ti = _make_ti(taxon_id=5077, genetic_code_id=11) + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(update=lambda db, taxon_id, ti_in: ti), + ) + resp = client.patch("/api/v1/taxonomy-info/5077", json={"genetic_code_id": 11}) + assert resp.status_code == 200 + assert resp.json()["genetic_code_id"] == 11 + + +def test_update_taxonomy_info_not_found(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(update=lambda db, taxon_id, ti_in: None), + ) + resp = client.patch("/api/v1/taxonomy-info/9999", json={"genetic_code_id": 11}) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Delete +# --------------------------------------------------------------------------- + + +def _override_admin(): + return SimpleNamespace(is_superuser=False, roles=["admin"], is_active=True) + + +def test_delete_taxonomy_info_success(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_admin + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + ti = _make_ti(taxon_id=5077) + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(delete=lambda db, taxon_id: ti), + ) + resp = client.delete("/api/v1/taxonomy-info/5077") + assert resp.status_code == 200 + assert resp.json()["taxon_id"] == 5077 + + +def test_delete_taxonomy_info_not_found(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_admin + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(delete=lambda db, taxon_id: None), + ) + resp = client.delete("/api/v1/taxonomy-info/9999") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Bulk import +# --------------------------------------------------------------------------- + + +def test_bulk_import_happy_path(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + result = BulkImportResponse( + created_count=2, + skipped_count=0, + message="TaxonomyInfo import complete. Created: 2, Skipped: 0", + ) + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(bulk_import=lambda db, data: result), + ) + payload = { + "5077": {"busco_odb12_dataset_name": "penicillium", "genetic_code_id": 1}, + "5303": {"busco_odb12_dataset_name": "agaricomycetes", "genetic_code_id": 1}, + } + resp = client.post("/api/v1/taxonomy-info/bulk-import", json=payload) + assert resp.status_code == 200 + body = resp.json() + assert body["created_count"] == 2 + assert body["skipped_count"] == 0 + + +def test_bulk_import_skips_missing_organisms(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + result = BulkImportResponse( + created_count=0, + skipped_count=1, + message="TaxonomyInfo import complete. Created: 0, Skipped: 1", + errors=["9999: organism with taxon_id 9999 does not exist"], + ) + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(bulk_import=lambda db, data: result), + ) + resp = client.post("/api/v1/taxonomy-info/bulk-import", json={"9999": {}}) + assert resp.status_code == 200 + body = resp.json() + assert body["skipped_count"] == 1 + assert any("does not exist" in e for e in body["errors"]) + + +def test_bulk_import_skips_duplicates(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + result = BulkImportResponse( + created_count=0, + skipped_count=1, + message="TaxonomyInfo import complete. Created: 0, Skipped: 1", + errors=["5077: taxonomy_info for taxon_id 5077 already exists"], + ) + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(bulk_import=lambda db, data: result), + ) + resp = client.post("/api/v1/taxonomy-info/bulk-import", json={"5077": {}}) + assert resp.status_code == 200 + body = resp.json() + assert body["skipped_count"] == 1 + assert any("already exists" in e for e in body["errors"]) + + +def test_bulk_import_rejects_mismatched_inner_taxon_id(monkeypatch): + client = TestClient(app) + app.dependency_overrides[ti_module.get_current_active_user] = _override_user + app.dependency_overrides[ti_module.get_db] = _override_db(_FakeSession()) + result = BulkImportResponse( + created_count=0, + skipped_count=1, + message="TaxonomyInfo import complete. Created: 0, Skipped: 1", + errors=["5077: inner taxon_id (9999) does not match top-level key"], + ) + monkeypatch.setattr( + ti_module, + "taxonomy_info_service", + SimpleNamespace(bulk_import=lambda db, data: result), + ) + resp = client.post( + "/api/v1/taxonomy-info/bulk-import", json={"5077": {"taxon_id": 9999}} + ) + assert resp.status_code == 200 + body = resp.json() + assert body["skipped_count"] == 1 + assert any("does not match" in e for e in body["errors"]) + + +# --------------------------------------------------------------------------- +# Schema-level assertions +# --------------------------------------------------------------------------- + + +def test_organism_schema_has_no_augustus_dataset_name(): + """OrganismSchema must not expose augustus_dataset_name at the top level.""" + fields = OrganismSchema.model_fields + assert "augustus_dataset_name" not in fields + + +def test_organism_schema_has_taxonomy_info_field(): + """OrganismSchema must expose nested taxonomy_info.""" + fields = OrganismSchema.model_fields + assert "taxonomy_info" in fields diff --git a/tests/unit/services/test_assembly_helper.py b/tests/unit/services/test_assembly_helper.py index 2529f67..1e89408 100644 --- a/tests/unit/services/test_assembly_helper.py +++ b/tests/unit/services/test_assembly_helper.py @@ -40,7 +40,7 @@ def test_illumina_hic(self): experiments = [ Mock(platform="ILLUMINA", library_strategy="Hi-C"), ] - with pytest.raises(ValueError, match="No valid sequencing platforms detected"): + with pytest.raises(ValueError, match="No valid data types detected in experiments"): determine_assembly_data_types(experiments) def test_illumina_wgs_treated_as_hic(self): @@ -111,12 +111,12 @@ def test_no_valid_platforms_raises_error(self): experiments = [ Mock(platform="UNKNOWN", library_strategy="WGS"), ] - with pytest.raises(ValueError, match="No valid sequencing platforms detected"): + with pytest.raises(ValueError, match="No valid data types detected in experiments"): determine_assembly_data_types(experiments) def test_empty_experiments_raises_error(self): """Test that empty experiments list raises ValueError.""" - with pytest.raises(ValueError, match="No valid sequencing platforms detected"): + with pytest.raises(ValueError, match="No valid data types detected in experiments"): determine_assembly_data_types([]) def test_none_platform_ignored(self):