From c283022d37ebf96921438c07af1bc961f19b5e52 Mon Sep 17 00:00:00 2001 From: Guilherme de Freitas Date: Wed, 15 Apr 2026 16:47:15 +0100 Subject: [PATCH 1/5] Add tomogram feature endpoints --- database/data.sql | 20 +++++++++--------- src/pato/crud/tomograms.py | 35 +++++++++++++++++++++++++------- src/pato/models/response.py | 7 ++++++- src/pato/routes/tomograms.py | 16 ++++++++++----- tests/tomograms/test_feature.py | 23 +++++++++++++++++++++ tests/tomograms/test_features.py | 13 ++++++++++++ 6 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 tests/tomograms/test_feature.py create mode 100644 tests/tomograms/test_features.py diff --git a/database/data.sql b/database/data.sql index da78449..71f7c2c 100644 --- a/database/data.sql +++ b/database/data.sql @@ -4730,7 +4730,7 @@ CREATE TABLE `Movie` ( `nominalDefocus` float unsigned DEFAULT NULL COMMENT 'Nominal defocus, Units: A', `angle` float DEFAULT NULL COMMENT 'unit: degrees relative to perpendicular to beam', `fluence` float DEFAULT NULL COMMENT 'accumulated electron fluence from start to end of acquisition of this movie (commonly, but incorrectly, referred to as ‘dose’)', - `numberOfFrames` int(11) unsigned DEFAULT NULL COMMENT 'number of frames per movie. This should be equivalent to the number of MotionCorrectionDrift entries, but the latter is a property of data analysis, whereas the number of frames is an intrinsic property of acquisition.', + `numberOfFrames` int(11) unsigned DEFAULT NULL COMMENT 'number of frames per movie. This should be equivalent to the number of MotionCorrectionDrift entries, but the latter is a property of data analysis, whereas the number of frames is an intrinsic property of acquisition.', `foilHoleId` int(11) unsigned DEFAULT NULL, `templateLabel` int(10) unsigned DEFAULT NULL, PRIMARY KEY (`movieId`), @@ -5499,9 +5499,9 @@ CREATE TABLE `ProcessedTomogram` ( LOCK TABLES `ProcessedTomogram` WRITE; /*!40000 ALTER TABLE `ProcessedTomogram` DISABLE KEYS */; INSERT INTO `ProcessedTomogram` VALUES -(1,3,'/dls/test.denoised.mrc','Denoised',NULL), -(2,3,'/dls/test.denoised_segmented.mrc','Segmented',NULL), -(3,3,'/dls/test.picked.cbox','Picked',NULL); +(1,3,'/dls/test.denoised.mrc','Denoised','Ribosome'), +(2,3,'/dls/test.denoised_segmented.mrc','Segmented','Microtubule'), +(3,3,'/dls/test.picked.cbox','Picked','Membrane'); /*!40000 ALTER TABLE `ProcessedTomogram` ENABLE KEYS */; UNLOCK TABLES; @@ -7842,8 +7842,8 @@ DROP TABLE IF EXISTS `TiltImageAlignment`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; CREATE TABLE `TiltImageAlignment` ( - `movieId` int(11) unsigned NOT NULL COMMENT 'FK to Movie table', - `tomogramId` int(11) unsigned NOT NULL COMMENT 'FK to Tomogram table; tuple (movieID, tomogramID) is unique', + `movieId` int(11) unsigned NOT NULL COMMENT 'FK to Movie table', + `tomogramId` int(11) unsigned NOT NULL COMMENT 'FK to Tomogram table; tuple (movieID, tomogramID) is unique', `defocusU` float DEFAULT NULL COMMENT 'unit: Angstroms', `defocusV` float DEFAULT NULL COMMENT 'unit: Angstroms', `psdFile` varchar(255) DEFAULT NULL, @@ -7891,10 +7891,10 @@ DROP TABLE IF EXISTS `Tomogram`; /*!40101 SET character_set_client = utf8mb4 */; CREATE TABLE `Tomogram` ( `tomogramId` int(11) unsigned NOT NULL AUTO_INCREMENT, - `dataCollectionId` int(11) unsigned DEFAULT NULL COMMENT 'FK to DataCollection table', + `dataCollectionId` int(11) unsigned DEFAULT NULL COMMENT 'FK to DataCollection table', `autoProcProgramId` int(10) unsigned DEFAULT NULL COMMENT 'FK, gives processing times/status and software information', - `volumeFile` varchar(255) DEFAULT NULL COMMENT '.mrc file representing the reconstructed tomogram volume', - `stackFile` varchar(255) DEFAULT NULL COMMENT '.mrc file containing the motion corrected images ordered by angle used as input for the reconstruction', + `volumeFile` varchar(255) DEFAULT NULL COMMENT '.mrc file representing the reconstructed tomogram volume', + `stackFile` varchar(255) DEFAULT NULL COMMENT '.mrc file containing the motion corrected images ordered by angle used as input for the reconstruction', `sizeX` int(11) unsigned DEFAULT NULL COMMENT 'unit: pixels', `sizeY` int(11) unsigned DEFAULT NULL COMMENT 'unit: pixels', `sizeZ` int(11) unsigned DEFAULT NULL COMMENT 'unit: pixels', @@ -8553,4 +8553,4 @@ UNLOCK TABLES; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-04-02 10:28:45 +-- Dump completed on 2026-04-15 16:45:45 diff --git a/src/pato/crud/tomograms.py b/src/pato/crud/tomograms.py index 7d2b755..5c4ee6c 100644 --- a/src/pato/crud/tomograms.py +++ b/src/pato/crud/tomograms.py @@ -1,5 +1,5 @@ import re -from typing import Literal, Optional +from typing import List, Literal, Optional from fastapi import HTTPException from lims_utils.tables import ( @@ -13,12 +13,7 @@ from sqlalchemy import Column, func, select from sqlalchemy import func as f -from ..models.response import ( - CtfTiltAlign, - DataPoint, - FullMovieWithTilt, - ItemList, -) +from ..models.response import CtfTiltAlign, DataPoint, FeatureType, FullMovieWithTilt, ItemList from ..utils.database import db from ..utils.generic import MovieType, parse_json_file, validate_path @@ -71,6 +66,32 @@ def _get_movie(tomogramId: int, movie_type: MovieType, image_type: Literal["thum base_path = _get_generic_tomogram_file(tomogramId, column) return _prepend_denoise(base_path, image_type, movie_type) +@validate_path +def get_feature(tomogramId: int, feature: FeatureType): + path = db.session.scalar( + select(ProcessedTomogram.filePath).filter( + ProcessedTomogram.tomogramId == tomogramId, + ProcessedTomogram.feature == feature.capitalize(), + ) + ) + + if not path: + raise HTTPException(status_code=404, detail=f"No {feature} feature found for this tomogram") + + return path + +def get_features(tomogramId: int): + features: List[str] = db.session.scalars( + select(f.lower(ProcessedTomogram.feature)).filter( + ProcessedTomogram.tomogramId == tomogramId, + ProcessedTomogram.feature.is_not(None) + ) + ) + + if not features: + return [] + + return features @validate_path def _get_shift_plot_path(tomogramId: int): diff --git a/src/pato/models/response.py b/src/pato/models/response.py index 9e2e37f..83e81a3 100644 --- a/src/pato/models/response.py +++ b/src/pato/models/response.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import Generic, Optional, TypeVar +from typing import Generic, Literal, Optional, TypeVar from lims_utils.models import Paged from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -17,6 +17,8 @@ class StateEnum(str, Enum): class OrmBaseModel(BaseModel): model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) +type FeatureType = Literal["ribosome", "microtubule", "membrane", "tric"] + class DataPoint(OrmBaseModel): x: str | float @@ -377,3 +379,6 @@ class FoilHole(BaseModel): particleCount: Optional[float] = None resolution: Optional[float] = None astigmatism: Optional[float] = None + +class FeatureList(BaseModel): + features: list[FeatureType] diff --git a/src/pato/routes/tomograms.py b/src/pato/routes/tomograms.py index beaec48..1df05c2 100644 --- a/src/pato/routes/tomograms.py +++ b/src/pato/routes/tomograms.py @@ -5,11 +5,7 @@ from ..auth import Permissions from ..crud import tomograms as crud -from ..models.response import ( - CtfTiltAlign, - DataPoint, - ItemList, -) +from ..models.response import CtfTiltAlign, DataPoint, FeatureList, FeatureType, ItemList from ..utils.generic import MovieType auth = Permissions.tomogram @@ -37,6 +33,16 @@ def get_movie(tomogramId: int = Depends(auth), movieType: MovieType = None): """Get tomogram movie image""" return crud.get_movie_path(tomogramId, movieType) +@router.get("/{tomogramId}/features/{feature}", response_class=FileResponse) +def get_feature(feature: FeatureType, tomogramId: int = Depends(auth)): + """Get tomogram feature""" + return crud.get_feature(tomogramId, feature) + +@router.get("/{tomogramId}/features", response_model=FeatureList) +def get_features(tomogramId: int = Depends(auth)): + """Get tomogram features""" + # Avoids prematurely converting to enum, which would fail validation + return FeatureList(features=crud.get_features(tomogramId)) @router.get("/{tomogramId}/projection", response_class=FileResponse) def get_projection(axis: Literal["xy", "xz"], tomogramId: int = Depends(auth)): diff --git a/tests/tomograms/test_feature.py b/tests/tomograms/test_feature.py new file mode 100644 index 0000000..445a320 --- /dev/null +++ b/tests/tomograms/test_feature.py @@ -0,0 +1,23 @@ +from unittest.mock import patch + +from tests.conftest import mock_send + + +def test_get(mock_permissions, client): + """Get tomogram feature""" + with patch("pato.routes.tomograms.FileResponse.__call__", new=mock_send): + resp = client.get("/tomograms/3/features/membrane") + assert resp.status_code == 200 + + +def test_file_not_found(mock_permissions, exists_mock, client): + """Try to get feature that does not exist on disk""" + exists_mock.return_value = False + resp = client.get("/tomograms/3/features/membrane") + assert resp.status_code == 404 + + +def test_inexistent_db(mock_permissions, client): + """Try to get feature not in database""" + resp = client.get("/tomograms/3/features/ribosome") + assert resp.status_code == 404 diff --git a/tests/tomograms/test_features.py b/tests/tomograms/test_features.py new file mode 100644 index 0000000..4672564 --- /dev/null +++ b/tests/tomograms/test_features.py @@ -0,0 +1,13 @@ +def test_get(mock_permissions, client): + """Get tomogram features""" + resp = client.get("/tomograms/3/features") + assert resp.status_code == 200 + + assert len(resp.json()["features"]) == 3 + +def test_no_features(mock_permissions, client): + """Should return empty array if tomogram has no features""" + resp = client.get("/tomograms/1/features") + assert resp.status_code == 200 + + assert len(resp.json()["features"]) == 0 From 5133732bdd735a4c4d7bd3745bec298c825d7070 Mon Sep 17 00:00:00 2001 From: Guilherme de Freitas Date: Thu, 16 Apr 2026 09:45:45 +0100 Subject: [PATCH 2/5] Fix tests --- tests/tomograms/test_feature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tomograms/test_feature.py b/tests/tomograms/test_feature.py index 445a320..fac6b8e 100644 --- a/tests/tomograms/test_feature.py +++ b/tests/tomograms/test_feature.py @@ -19,5 +19,5 @@ def test_file_not_found(mock_permissions, exists_mock, client): def test_inexistent_db(mock_permissions, client): """Try to get feature not in database""" - resp = client.get("/tomograms/3/features/ribosome") + resp = client.get("/tomograms/3/features/tric") assert resp.status_code == 404 From 5d4c0046abae4f4b2d77d428e414802666395bc6 Mon Sep 17 00:00:00 2001 From: Guilherme de Freitas Date: Wed, 13 May 2026 14:48:26 +0100 Subject: [PATCH 3/5] Display B24 sessions --- src/pato/crud/sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pato/crud/sessions.py b/src/pato/crud/sessions.py index 2eaab64..2bc95cc 100644 --- a/src/pato/crud/sessions.py +++ b/src/pato/crud/sessions.py @@ -92,7 +92,7 @@ def get_sessions( ) query = select(*unravel(BLSession), *fields).filter( - BLSession.beamLineName.like("m%"), + or_(BLSession.beamLineName.like("m%"), BLSession.beamLineName.like("b24%")) ) if proposal is not None: From de85d4aa36ee00dafca861adca75694fd8b3b450 Mon Sep 17 00:00:00 2001 From: Guilherme de Freitas Date: Fri, 22 May 2026 10:52:09 +0100 Subject: [PATCH 4/5] Add API prefix --- src/pato/main.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/pato/main.py b/src/pato/main.py index 136a9fe..83b39c0 100644 --- a/src/pato/main.py +++ b/src/pato/main.py @@ -1,3 +1,4 @@ +import os from contextlib import asynccontextmanager from threading import Thread @@ -38,8 +39,10 @@ async def lifespan(app: FastAPI): app = FastAPI(version=__version__, lifespan=lifespan) +api = FastAPI() + if Config.cors: - app.add_middleware( + api.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, @@ -47,26 +50,28 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) -app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5) +api.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5) -@app.middleware("http") +@api.middleware("http") async def get_session_as_middleware(request, call_next): with get_session(session_factory): return await call_next(request) -app.add_exception_handler(HTTPException, log_exception_handler) +api.add_exception_handler(HTTPException, log_exception_handler) + +api.include_router(sessions.router) +api.include_router(tomograms.router) +api.include_router(movies.router) +api.include_router(collections.router) +api.include_router(groups.router) +api.include_router(proposals.router) +api.include_router(autoproc.router) +api.include_router(feedback.router) +api.include_router(procjob.router) +api.include_router(grid_squares.router) +api.include_router(foil_holes.router) +api.include_router(persons.router) -app.include_router(sessions.router) -app.include_router(tomograms.router) -app.include_router(movies.router) -app.include_router(collections.router) -app.include_router(groups.router) -app.include_router(proposals.router) -app.include_router(autoproc.router) -app.include_router(feedback.router) -app.include_router(procjob.router) -app.include_router(grid_squares.router) -app.include_router(foil_holes.router) -app.include_router(persons.router) +app.mount(os.getenv("MOUNT_POINT", "/api"), api) From 924a76f8f69730078fa748aa3aa1af85753ba9f0 Mon Sep 17 00:00:00 2001 From: Guilherme de Freitas Date: Fri, 22 May 2026 10:56:14 +0100 Subject: [PATCH 5/5] Fix tests --- tests/conftest.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 99587cc..8414b80 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ from pato.auth import User from pato.auth.micro import oauth2_scheme -from pato.main import app +from pato.main import api from pato.utils.database import db from .users import admin @@ -22,8 +22,8 @@ ) Session = sessionmaker() -app.user_middleware.clear() -app.middleware_stack = app.build_middleware_stack() +api.user_middleware.clear() +api.middleware_stack = api.build_middleware_stack() async def mock_send(_, _1, _2, s): @@ -51,7 +51,7 @@ def new_perms(item_id, _, _0, _1=""): @pytest.fixture(scope="function") def client(): - client = TestClient(app) + client = TestClient(api) conn = engine.connect() transaction = conn.begin() session = Session(bind=conn, join_transaction_mode="create_savepoint") @@ -71,18 +71,18 @@ def empty_method(): @pytest.fixture(scope="function", params=[admin]) def mock_user(request): try: - old_overrides = app.dependency_overrides[User] + old_overrides = api.dependency_overrides[User] except KeyError: old_overrides = empty_method - app.dependency_overrides[User] = lambda: request.param + api.dependency_overrides[User] = lambda: request.param yield - app.dependency_overrides[User] = old_overrides + api.dependency_overrides[User] = old_overrides @pytest.fixture(scope="function") def mock_permissions(request): - app.dependency_overrides[oauth2_scheme] = lambda: HTTPAuthorizationCredentials(credentials="a", scheme="Bearer") + api.dependency_overrides[oauth2_scheme] = lambda: HTTPAuthorizationCredentials(credentials="a", scheme="Bearer") with patch("pato.auth.micro._check_perms", new=new_perms) as _fixture: yield _fixture