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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "mavedb"
version = "2026.2.7.1"
version = "2026.2.7.2"
description = "API for MaveDB, the database of Multiplexed Assays of Variant Effect."
license = "AGPL-3.0-only"
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion src/mavedb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
logger = module_logging.getLogger(__name__)

__project__ = "mavedb-api"
__version__ = "2026.2.7.1"
__version__ = "2026.2.7.2"

logger.info(f"MaveDB {__version__}")

Expand Down
10 changes: 10 additions & 0 deletions src/mavedb/routers/experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ def search_experiments(search: ExperimentsSearch, db: Session = Depends(deps.get
"""
Search experiments.
"""
# This endpoint is unauthenticated, so it serves published experiments only. `build_search_experiments_query_filter`
# narrows by owner or contributor, not by visibility, and receives None here; without this the search would return
# every unpublished experiment in the database. Private experiments are reached through /me/experiments/search.
if search.published is False:
raise HTTPException(
status_code=422,
detail="Cannot search for private experiments except in the context of the current user's data.",
)
search.published = True

items = _search_experiments(db, None, search)
return [enrich_experiment_with_num_score_sets(exp, None) for exp in items]

Expand Down
27 changes: 16 additions & 11 deletions src/mavedb/routers/score_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,17 +582,18 @@ async def fetch_score_set_by_urn(
:param user: The user who has requested the score set. If the user does not have read permission, the score set will
not be returned. If None, the score set is returned only if publicly visible.
:param owner_or_contributor: If not None, require that the result be a score set of which this user is owner or
contributor.
contributor. This is an ownership requirement, not a visibility one: it does not admit score sets that are
merely public. Combining it with only_published therefore yields published score sets owned by this user.
:param only_published: If true, only return the score set if it is published.
:return: The score set, or None if the URL was not found or refers to a private score set not owned by the specified
user.
:return: The score set.
:raises HTTPException: 404 if no score set matches the URN and the supplied filters, or 500 if more than one
does. Read permission is asserted on the result and raises through assert_permission.
"""
try:
query = db.query(ScoreSet).filter(ScoreSet.urn == urn)
if owner_or_contributor is not None:
query = query.filter(
or_(
ScoreSet.private.is_(False),
ScoreSet.created_by_id == owner_or_contributor.user.id,
ScoreSet.contributors.any(Contributor.orcid_id == owner_or_contributor.user.username),
)
Expand Down Expand Up @@ -734,15 +735,17 @@ def search_score_sets(
)

score_sets, num_score_sets = _search_score_sets(db, None, search).values()

# Unconditional, because this enrichment is also what filters the nested experiment's score set URNs by
# permission. Serializing the ORM experiment directly instead reaches SavedExperiment's score_set_urns
# validator, which lists every score set on the experiment, disclosing the URNs of private ones.
enriched_score_sets = []
if search.include_experiment_score_set_urns_and_count:
for ss in score_sets:
enriched_experiment = enrich_experiment_with_num_score_sets(ss.experiment, user_data)
response_item = score_set.ScoreSet.model_validate(ss).copy(update={"experiment": enriched_experiment})
enriched_score_sets.append(response_item)
score_sets = enriched_score_sets
for ss in score_sets:
enriched_experiment = enrich_experiment_with_num_score_sets(ss.experiment, user_data)
response_item = score_set.ScoreSet.model_validate(ss).copy(update={"experiment": enriched_experiment})
enriched_score_sets.append(response_item)

return {"score_sets": score_sets, "num_score_sets": num_score_sets}
return {"score_sets": enriched_score_sets, "num_score_sets": num_score_sets}


@router.post("/score-sets/search/filter-options", status_code=200, response_model=ScoreSetsSearchFilterOptionsResponse)
Expand Down Expand Up @@ -1763,6 +1766,8 @@ async def create_score_set(

save_to_logging_context({"requested_superseded_score_set": item_create.superseded_score_set_urn})
if item_create.superseded_score_set_urn is not None:
# Passing user_data as owner_or_contributor is what authorizes the supersession: the fetch returns
# only published score sets this user owns or contributes to. There is no Action for supersession yet.
superseded_score_set = await fetch_score_set_by_urn(
db, item_create.superseded_score_set_urn, user_data, user_data, True
)
Expand Down
21 changes: 16 additions & 5 deletions src/mavedb/routers/target_genes.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import Any, List, Optional

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, selectinload
from sqlalchemy.orm import Query, Session, selectinload

from mavedb import deps
from mavedb.lib.authentication import get_current_user
Expand Down Expand Up @@ -83,16 +83,27 @@ def list_target_genes(
return sorted(validated_items, key=lambda i: i.name)


def _published_target_genes(db: Session) -> Query[TargetGene]:
"""
Query the target genes belonging to published score sets.

The two routes below aggregate over the whole table and are unauthenticated, so they have no entity to
assert a permission on. The parent score set's published state is what keeps the target names and
categories of unpublished work out of the response.
"""
return db.query(TargetGene).join(ScoreSet).filter(ScoreSet.published_date.is_not(None))


@router.get("/target-genes/names", status_code=200, response_model=List[str], summary="List target gene names")
def list_target_gene_names(
*,
db: Session = Depends(deps.get_db),
) -> Any:
"""
List distinct target gene names, in alphabetical order.
List distinct target gene names from published score sets, in alphabetical order.
"""

items = db.query(TargetGene).all()
items = _published_target_genes(db).all()
names = map(lambda item: item.name, items)
return sorted(list(set(names)))

Expand All @@ -105,10 +116,10 @@ def list_target_gene_categories(
db: Session = Depends(deps.get_db),
) -> Any:
"""
List distinct target genes categories, in alphabetical order.
List distinct target gene categories from published score sets, in alphabetical order.
"""

items = db.query(TargetGene).all()
items = _published_target_genes(db).all()
categories = map(lambda item: item.category, items)
return sorted(list(set(categories)))

Expand Down
1 change: 0 additions & 1 deletion src/mavedb/view_models/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ class ScoreSetsSearch(BaseModel):
publication_identifiers: Optional[list[str]] = None
keywords: Optional[list[ControlledKeywordSearch]] = None
text: Optional[str] = None
include_experiment_score_set_urns_and_count: Optional[bool] = True
offset: Optional[int] = None
limit: Optional[int] = None

Expand Down
93 changes: 85 additions & 8 deletions tests/routers/test_experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -1574,14 +1574,61 @@ def test_users_get_one_score_set_from_own_experiment_with_a_superseding_score_se
assert pub_score_set["urn"] not in response_data["scoreSetUrns"]


def test_search_experiments(session, client, setup_router_db):
experiment = create_experiment(client)
def _publish_experiment(session, data_provider, client, data_files, update=None):
"""Publish an experiment, and return it.

Publishing a score set is the only path that publishes its experiment, so an experiment cannot be
published without one.
"""
experiment = create_experiment(client, update)
score_set = create_seq_score_set(client, experiment["urn"])
score_set = mock_worker_variant_insertion(client, session, data_provider, score_set, data_files / "scores.csv")

with patch.object(arq.ArqRedis, "enqueue_job", return_value=None):
published_score_set = publish_score_set(client, score_set["urn"])

return published_score_set["experiment"]


def test_search_experiments(session, data_provider, client, setup_router_db, data_files):
experiment = _publish_experiment(session, data_provider, client, data_files)
search_payload = {"text": experiment["shortDescription"]}
response = client.post("/api/v1/experiments/search", json=search_payload)
assert response.status_code == 200
assert response.json()[0]["title"] == experiment["title"]


def test_search_experiments_excludes_unpublished(session, data_provider, client, setup_router_db, data_files):
"""The public search endpoint serves published experiments only.

Both experiments match the search text, so this fails whether the visibility filter is too permissive
or too restrictive. Asserting only that an unpublished experiment is absent would also pass if the
search returned nothing at all.
"""
published = _publish_experiment(
session, data_provider, client, data_files, update={"title": "Published Experiment"}
)
unpublished = create_experiment(client, update={"title": "Unpublished Experiment"})

search_payload = {"text": TEST_MINIMAL_EXPERIMENT["shortDescription"]}
response = client.post("/api/v1/experiments/search", json=search_payload)

assert response.status_code == 200
returned_urns = [item["urn"] for item in response.json()]
assert published["urn"] in returned_urns
assert unpublished["urn"] not in returned_urns


def test_search_experiments_rejects_explicit_unpublished_search(session, client, setup_router_db):
"""Unpublished experiments are reached through /me/experiments/search, never this endpoint."""
response = client.post("/api/v1/experiments/search", json={"published": False})
assert response.status_code == 422
assert (
response.json()["detail"]
== "Cannot search for private experiments except in the context of the current user's data."
)


def test_search_my_experiments(session, client, setup_router_db):
experiment = create_experiment(client)
search_payload = {"text": experiment["shortDescription"]}
Expand Down Expand Up @@ -1934,8 +1981,8 @@ def test_search_score_sets_for_my_experiments(session, client, setup_router_db,
)


def test_search_their_experiments(session, client, setup_router_db):
experiment = create_experiment(client)
def test_search_their_experiments(session, data_provider, client, setup_router_db, data_files):
experiment = _publish_experiment(session, data_provider, client, data_files)
change_ownership(session, experiment["urn"], ExperimentDbModel)
change_ownership(session, experiment["experimentSetUrn"], ExperimentSetDbModel)
search_payload = {"text": experiment["shortDescription"]}
Expand All @@ -1945,6 +1992,22 @@ def test_search_their_experiments(session, client, setup_router_db):
assert response.json()[0]["createdBy"]["firstName"] == EXTRA_USER["first_name"]


def test_cannot_search_their_unpublished_experiments(session, client, setup_router_db):
"""Another user's unpublished experiment is not disclosed by the public search endpoint.

Regression test: build_search_experiments_query_filter narrows by owner or contributor and receives
None from this endpoint, so before the visibility filter was added this returned the experiment along
with its owner's name and ORCID iD.
"""
experiment = create_experiment(client)
change_ownership(session, experiment["urn"], ExperimentDbModel)
change_ownership(session, experiment["experimentSetUrn"], ExperimentSetDbModel)
search_payload = {"text": experiment["shortDescription"]}
response = client.post("/api/v1/experiments/search", json=search_payload)
assert response.status_code == 200
assert experiment["urn"] not in [item["urn"] for item in response.json()]


def test_search_not_my_experiments(session, client, setup_router_db):
experiment = create_experiment(client)
change_ownership(session, experiment["urn"], ExperimentDbModel)
Expand All @@ -1955,13 +2018,27 @@ def test_search_not_my_experiments(session, client, setup_router_db):
assert len(response.json()) == 0


def test_anonymous_search_experiments(session, client, anonymous_app_overrides, setup_router_db):
experiment = create_experiment(client)
search_payload = {"text": experiment["shortDescription"]}
def test_anonymous_search_experiments(
session, data_provider, client, anonymous_app_overrides, setup_router_db, data_files
):
"""An anonymous caller sees published experiments, and only those.

Both experiments match the search text, so this fails whether the visibility filter is too permissive
or too restrictive.
"""
published = _publish_experiment(
session, data_provider, client, data_files, update={"title": "Published Experiment"}
)
unpublished = create_experiment(client, update={"title": "Unpublished Experiment"})

search_payload = {"text": TEST_MINIMAL_EXPERIMENT["shortDescription"]}
with DependencyOverrider(anonymous_app_overrides):
response = client.post("/api/v1/experiments/search", json=search_payload)

assert response.status_code == 200
assert response.json()[0]["title"] == experiment["title"]
returned_urns = [item["urn"] for item in response.json()]
assert published["urn"] in returned_urns
assert unpublished["urn"] not in returned_urns


def test_anonymous_cannot_search_my_experiments(session, client, anonymous_app_overrides, setup_router_db):
Expand Down
Loading
Loading