Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""rename calibration publication relation classification to evidence

Revision ID: 659999dec5d9
Revises: dcf8572d3a17
Create Date: 2026-02-23 00:00:00.000000

"""

from alembic import op

# revision identifiers, used by Alembic.
revision = "659999dec5d9"
down_revision = "dcf8572d3a17"
branch_labels = None
depends_on = None


def upgrade():
op.execute(
"""
UPDATE score_calibration_publication_identifiers
SET relation = 'evidence'
WHERE relation = 'classification'
"""
)


def downgrade():
op.execute(
"""
UPDATE score_calibration_publication_identifiers
SET relation = 'classification'
WHERE relation = 'evidence'
"""
)
1 change: 1 addition & 0 deletions src/mavedb/lib/permissions/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ class Action(Enum):
PUBLISH = "publish"
ADD_BADGE = "add_badge"
CHANGE_RANK = "change_rank"
ADD_CALIBRATION = "add_calibration"
10 changes: 6 additions & 4 deletions src/mavedb/lib/permissions/score_calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,13 @@ def _handle_change_rank_action(
# System admins may change the rank of any ScoreCalibration.
if roles_permitted(active_roles, [UserRole.admin]):
return PermissionResponse(True)
# Owners may change the rank of their own ScoreCalibration.
if user_is_owner:

# Score set contributors may always change the rank of calibrations on their score set.
if user_is_contributor_to_score_set:
return PermissionResponse(True)
# If the calibration is investigator provided, contributors to the ScoreSet may change its rank.
if entity.investigator_provided and user_is_contributor_to_score_set:
# Owners may change the rank of their own investigator-provided calibrations.
# Community calibration owners may not — the score set team controls ranking of community contributions.
if entity.investigator_provided and user_is_owner:
return PermissionResponse(True)

user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set)
Expand Down
42 changes: 41 additions & 1 deletion src/mavedb/lib/permissions/score_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def has_permission(user_data: Optional[UserData], entity: ScoreSet, action: Acti
Args:
user_data: The user's authentication data and roles. None for anonymous users.
entity: The ScoreSet entity to check permissions for.
action: The action to be performed (READ, UPDATE, DELETE, PUBLISH, SET_SCORES).
action: The action to be performed (READ, UPDATE, DELETE, PUBLISH, SET_SCORES, ADD_CALIBRATION).

Returns:
PermissionResponse: Contains permission result, HTTP status code, and message.
Expand Down Expand Up @@ -54,6 +54,7 @@ def has_permission(user_data: Optional[UserData], entity: ScoreSet, action: Acti
Action.DELETE: _handle_delete_action,
Action.PUBLISH: _handle_publish_action,
Action.SET_SCORES: _handle_set_scores_action,
Action.ADD_CALIBRATION: _handle_add_calibration_action,
}

if action not in handlers:
Expand Down Expand Up @@ -253,3 +254,42 @@ def _handle_set_scores_action(
return PermissionResponse(True)

return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set")


def _handle_add_calibration_action(
user_data: Optional[UserData],
entity: ScoreSet,
private: bool,
user_is_owner: bool,
user_is_contributor: bool,
active_roles: list[UserRole],
) -> PermissionResponse:
"""
Handle ADD_CALIBRATION action permission check for ScoreSet entities.

Any authenticated user may add a calibration to a published ScoreSet.
For private ScoreSets, only owners, contributors, and admins may add calibrations.

Args:
user_data: The user's authentication data.
entity: The ScoreSet entity being accessed.
private: Whether the ScoreSet is private.
user_is_owner: Whether the user owns the ScoreSet.
user_is_contributor: Whether the user is a contributor to the ScoreSet.
active_roles: List of the user's active roles.

Returns:
PermissionResponse: Permission result with appropriate HTTP status.
"""
## Allow add calibration access under the following conditions:
# Any authenticated user may add a calibration to a published score set.
if not private and user_data is not None:
return PermissionResponse(True)
# The owner or contributors may add a calibration to a private score set.
if user_is_owner or user_is_contributor:
return PermissionResponse(True)
# Admins may add a calibration to any score set.
if roles_permitted(active_roles, [UserRole.admin]):
return PermissionResponse(True)

return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set")
20 changes: 10 additions & 10 deletions src/mavedb/lib/score_calibrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,15 @@ async def _create_score_calibration(
publication identifier associations.

For each publication source listed in the incoming ScoreCalibrationCreate model
(threshold_sources, classification_sources, method_sources), this function
(threshold_sources, evidence_sources, method_sources), this function
ensures a corresponding PublicationIdentifier row exists (via
find_or_create_publication_identifier) and creates a
ScoreCalibrationPublicationIdentifierAssociation that links the identifier to
the new calibration under the appropriate relation type
(ScoreCalibrationRelation.threshold / .classification / .method).
(ScoreCalibrationRelation.threshold / .evidence / .method).

Fields in calibration_create that represent source lists or audit metadata
(threshold_sources, classification_sources, method_sources, created_at,
(threshold_sources, evidence_sources, method_sources, created_at,
created_by, modified_at, modified_by) are excluded when instantiating the
ScoreCalibration; audit fields created_by and modified_by are explicitly set
from the provided user_data. The resulting ScoreCalibration object includes
Expand Down Expand Up @@ -157,7 +157,7 @@ async def _create_score_calibration(
"""
relation_sources = (
(ScoreCalibrationRelation.threshold, calibration_create.threshold_sources or []),
(ScoreCalibrationRelation.classification, calibration_create.classification_sources or []),
(ScoreCalibrationRelation.evidence, calibration_create.evidence_sources or []),
(ScoreCalibrationRelation.method, calibration_create.method_sources or []),
)

Expand All @@ -182,7 +182,7 @@ async def _create_score_calibration(
exclude={
"functional_classifications",
"threshold_sources",
"classification_sources",
"evidence_sources",
"method_sources",
"score_set_urn",
},
Expand Down Expand Up @@ -344,15 +344,15 @@ async def modify_score_calibration(
2. Loads (via SELECT ... WHERE urn = :score_set_urn) the ScoreSet that will contain the calibration.
3. Reconciles publication identifier associations for three relation categories:
- threshold_sources -> ScoreCalibrationRelation.threshold
- classification_sources -> ScoreCalibrationRelation.classification
- evidence_sources -> ScoreCalibrationRelation.evidence
- method_sources -> ScoreCalibrationRelation.method
For each provided source identifier:
* Calls find_or_create_publication_identifier to obtain (or persist) the identifier row.
* Preserves an existing association if already present.
* Creates a new association if missing.
Any previously existing associations not referenced in the update are deleted from the session.
4. Updates mutable scalar fields on the calibration instance from calibration_update, excluding:
threshold_sources, classification_sources, method_sources, created_at, created_by,
threshold_sources, evidence_sources, method_sources, created_at, created_by,
modified_at, modified_by.
5. Reassigns the calibration to the resolved ScoreSet, replaces its association collection,
and stamps modified_by with the requesting user.
Expand All @@ -366,7 +366,7 @@ async def modify_score_calibration(
The existing calibration ORM instance to be modified (must be persistent or pending).
calibration_update : score_calibration.ScoreCalibrationModify
- score_set_urn (required)
- threshold_sources, classification_sources, method_sources (iterables of identifier objects)
- threshold_sources, evidence_sources, method_sources (iterables of identifier objects)
- Additional mutable calibration attributes.
user : User
Context for the authenticated user; the user to be recorded for audit.
Expand Down Expand Up @@ -415,7 +415,7 @@ async def modify_score_calibration(

relation_sources = (
(ScoreCalibrationRelation.threshold, calibration_update.threshold_sources or []),
(ScoreCalibrationRelation.classification, calibration_update.classification_sources or []),
(ScoreCalibrationRelation.evidence, calibration_update.evidence_sources or []),
(ScoreCalibrationRelation.method, calibration_update.method_sources or []),
)

Expand Down Expand Up @@ -460,7 +460,7 @@ async def modify_score_calibration(
if attr not in {
"functional_classifications",
"threshold_sources",
"classification_sources",
"evidence_sources",
"method_sources",
"created_at",
"created_by",
Expand Down
8 changes: 4 additions & 4 deletions src/mavedb/lib/validation/transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class TransformedScoreSetPublicationIdentifiers(TypedDict):

class TransformedCalibrationPublicationIdentifiers(TypedDict):
threshold_sources: list[PublicationIdentifier]
classification_sources: list[PublicationIdentifier]
evidence_sources: list[PublicationIdentifier]
method_sources: list[PublicationIdentifier]


Expand Down Expand Up @@ -108,7 +108,7 @@ def transform_score_calibration_publication_identifiers(
publication_identifiers: Optional[Sequence[ScoreCalibrationPublicationIdentifierAssociation]],
) -> TransformedCalibrationPublicationIdentifiers:
transformed_publication_identifiers = TransformedCalibrationPublicationIdentifiers(
threshold_sources=[], classification_sources=[], method_sources=[]
threshold_sources=[], evidence_sources=[], method_sources=[]
)

if not publication_identifiers:
Expand All @@ -119,10 +119,10 @@ def transform_score_calibration_publication_identifiers(
for assc in publication_identifiers
if assc.relation is ScoreCalibrationRelation.threshold
]
transformed_publication_identifiers["classification_sources"] = [
transformed_publication_identifiers["evidence_sources"] = [
TypeAdapter(PublicationIdentifier).validate_python(assc.publication)
for assc in publication_identifiers
if assc.relation is ScoreCalibrationRelation.classification
if assc.relation is ScoreCalibrationRelation.evidence
]
transformed_publication_identifiers["method_sources"] = [
TypeAdapter(PublicationIdentifier).validate_python(assc.publication)
Expand Down
2 changes: 1 addition & 1 deletion src/mavedb/models/enums/score_calibration_relation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@

class ScoreCalibrationRelation(enum.Enum):
threshold = "threshold"
classification = "classification"
evidence = "evidence"
method = "method"
10 changes: 3 additions & 7 deletions src/mavedb/routers/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,15 +318,11 @@ async def update_collection(

assert_permission(user_data, item, Action.UPDATE)

# Editors may update metadata, but not all editors can publish (which is just setting private to public).
if item.private and not item_update.private:
# Ensure users have permission to make the specific changes they are attempting to make.
if item_update.private is not None and item.private != item_update.private:
assert_permission(user_data, item, Action.PUBLISH)

# Unpublishing requires the same permissions as publishing.
if not item.private and item_update.private:
assert_permission(user_data, item, Action.PUBLISH)

if item_update.badge_name:
if item_update.badge_name is not None and item.badge_name != item_update.badge_name:
assert_permission(user_data, item, Action.ADD_BADGE)

# Handle score_set_urns: replace-all with implicit add/remove
Expand Down
31 changes: 15 additions & 16 deletions src/mavedb/routers/score_calibrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from mavedb import deps
from mavedb.lib.authentication import get_current_user
from mavedb.lib.authorization import require_current_user
from mavedb.lib.authorization import require_current_user_with_email
from mavedb.lib.flexible_model_loader import json_or_form_loader
from mavedb.lib.logging import LoggedRoute
from mavedb.lib.logging.context import (
Expand Down Expand Up @@ -218,7 +218,7 @@ async def create_score_calibration_route(
description=f"CSV file containing variant classifications. This file must contain two columns: '{calibration_variant_column_name}' and '{calibration_class_column_name}'.",
),
db: Session = Depends(deps.get_db),
user_data: UserData = Depends(require_current_user),
user_data: UserData = Depends(require_current_user_with_email),
) -> ScoreCalibration:
"""
Create a new score calibration.
Expand Down Expand Up @@ -261,9 +261,11 @@ async def create_score_calibration_route(

## Requirements
- The score set URN must be provided to associate the calibration with an existing score set
- User must have write permission on the associated score set
- User must have an email address associated with their account
- If uploading a classes_file, it must be a valid CSV with variant classification data

- User must have ADD_CALIBRATION permission on the score set (any authenticated user for
published sets; contributors/owners/admins for private sets)

## File Upload Details
The `classes_file` parameter accepts CSV files containing variant classification data.
The file should have appropriate headers and contain columns for variant urns and class names.
Expand All @@ -281,9 +283,7 @@ async def create_score_calibration_route(
logger.debug("ScoreSet not found", extra=logging_context())
raise HTTPException(status_code=404, detail=f"score set with URN '{calibration.score_set_urn}' not found")

# TODO#539: Allow any authenticated user to upload a score calibration for a score set, not just those with
# permission to update the score set itself.
assert_permission(user_data, score_set, Action.UPDATE)
assert_permission(user_data, score_set, Action.ADD_CALIBRATION)

if calibration.class_based and not classes_file:
raise HTTPException(
Expand Down Expand Up @@ -367,7 +367,7 @@ async def modify_score_calibration_route(
description=f"CSV file containing variant classifications. This file must contain two columns: '{calibration_variant_column_name}' and '{calibration_class_column_name}'.",
),
db: Session = Depends(deps.get_db),
user_data: UserData = Depends(require_current_user),
user_data: UserData = Depends(require_current_user_with_email),
) -> ScoreCalibration:
"""
Modify an existing score calibration by its URN.
Expand Down Expand Up @@ -409,8 +409,9 @@ async def modify_score_calibration_route(
```

## Requirements
- User must have an email address associated with their account
- User must have update permission on the calibration
- If changing the score_set_urn, user must have permission on the new score set
- If changing the score_set_urn, user must have ADD_CALIBRATION permission on the target score set
- All fields in the update are optional - only provided fields will be modified

## File Upload Details
Expand All @@ -435,9 +436,7 @@ async def modify_score_calibration_route(
status_code=404, detail=f"score set with URN '{calibration_update.score_set_urn}' not found"
)

# TODO#539: Allow any authenticated user to upload a score calibration for a score set, not just those with
# permission to update the score set itself.
assert_permission(user_data, score_set_update, Action.UPDATE)
assert_permission(user_data, score_set_update, Action.ADD_CALIBRATION)
else:
score_set_update = None

Expand Down Expand Up @@ -505,7 +504,7 @@ async def delete_score_calibration_route(
*,
urn: str,
db: Session = Depends(deps.get_db),
user_data: UserData = Depends(require_current_user),
user_data: UserData = Depends(require_current_user_with_email),
) -> None:
"""
Delete an existing score calibration by its URN.
Expand Down Expand Up @@ -542,7 +541,7 @@ async def promote_score_calibration_to_primary_route(
False, description="Whether to demote any existing primary calibration", alias="demoteExistingPrimary"
),
db: Session = Depends(deps.get_db),
user_data: UserData = Depends(require_current_user),
user_data: UserData = Depends(require_current_user_with_email),
) -> ScoreCalibration:
"""
Promote a score calibration to be the primary calibration for its associated score set.
Expand Down Expand Up @@ -608,7 +607,7 @@ def demote_score_calibration_from_primary_route(
*,
urn: str,
db: Session = Depends(deps.get_db),
user_data: UserData = Depends(require_current_user),
user_data: UserData = Depends(require_current_user_with_email),
) -> ScoreCalibration:
"""
Demote a score calibration from being the primary calibration for its associated score set.
Expand Down Expand Up @@ -647,7 +646,7 @@ def publish_score_calibration_route(
*,
urn: str,
db: Session = Depends(deps.get_db),
user_data: UserData = Depends(require_current_user),
user_data: UserData = Depends(require_current_user_with_email),
) -> ScoreCalibration:
"""
Publish a score calibration, making it publicly visible.
Expand Down
2 changes: 1 addition & 1 deletion src/mavedb/scripts/load_calibration_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ def main(db: Session, csv_path: str, delimiter: str, overwrite: bool, purge_publ
baseline_score_description=baseline_score_description,
threshold_sources=threshold_publications,
method_sources=method_publications,
classification_sources=calculation_publications,
evidence_sources=calculation_publications,
research_use_only=False,
functional_classifications=ranges,
notes=calibration_notes,
Expand Down
2 changes: 1 addition & 1 deletion src/mavedb/scripts/load_excalibr_calibrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ def main(db: Session, csv_path: str, dataset_map: str, overwrite: bool, remove:
score_set_urn=score_set.urn,
calibration_metadata={"prior_probability_pathogenicity": prior},
threshold_sources=[EXCALIBR_CALIBRATION_CITATION],
classification_sources=[EXCALIBR_CALIBRATION_CITATION],
evidence_sources=[EXCALIBR_CALIBRATION_CITATION],
method_sources=[EXCALIBR_CALIBRATION_CITATION],
)

Expand Down
Loading