Skip to content

Commit aec2010

Browse files
committed
feat: allow creation and modification of class based calibrations
- Added router functionality for validation and standardization of class based calibration files. - Added lib functionality for creation/modification of class based calibrations. - Invoked lib functionality from routers to allow client creation/modification of class based calibrations. - Introduced a new CSV file `calibration_classes.csv` containing variant URNs and their corresponding class names. - Implemented tests for creating and updating score calibrations using class-based classifications. - Enhanced existing test suite with parameterized tests to validate score calibration creation and modification. - Ensured that the response includes correct functional classifications and variant counts.
1 parent 3fda888 commit aec2010

File tree

6 files changed

+1259
-185
lines changed

6 files changed

+1259
-185
lines changed

src/mavedb/lib/score_calibrations.py

Lines changed: 196 additions & 111 deletions
Large diffs are not rendered by default.

src/mavedb/lib/score_sets.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,7 +1100,7 @@ def bulk_create_urns(n, score_set, reset_counter=False) -> list[str]:
11001100
return child_urns
11011101

11021102

1103-
def csv_data_to_df(file_data: BinaryIO) -> pd.DataFrame:
1103+
def csv_data_to_df(file_data: BinaryIO, induce_hgvs_cols: bool = True) -> pd.DataFrame:
11041104
extra_na_values = list(
11051105
set(
11061106
list(null_values_list)
@@ -1121,9 +1121,10 @@ def csv_data_to_df(file_data: BinaryIO) -> pd.DataFrame:
11211121
dtype={**{col: str for col in HGVSColumns.options()}, "scores": float},
11221122
)
11231123

1124-
for c in HGVSColumns.options():
1125-
if c not in ingested_df.columns:
1126-
ingested_df[c] = np.NaN
1124+
if induce_hgvs_cols:
1125+
for c in HGVSColumns.options():
1126+
if c not in ingested_df.columns:
1127+
ingested_df[c] = np.NaN
11271128

11281129
return ingested_df
11291130

src/mavedb/routers/score_calibrations.py

Lines changed: 214 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import logging
22
from typing import Optional
33

4-
from fastapi import APIRouter, Depends, HTTPException, Query
4+
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
55
from sqlalchemy.orm import Session, selectinload
66

77
from mavedb import deps
88
from mavedb.lib.authentication import UserData, get_current_user
99
from mavedb.lib.authorization import require_current_user
10+
from mavedb.lib.flexible_model_loader import json_or_form_loader
1011
from mavedb.lib.logging import LoggedRoute
1112
from mavedb.lib.logging.context import (
1213
logging_context,
@@ -20,7 +21,11 @@
2021
modify_score_calibration,
2122
promote_score_calibration_to_primary,
2223
publish_score_calibration,
24+
variant_classification_df_to_dict,
2325
)
26+
from mavedb.lib.score_sets import csv_data_to_df
27+
from mavedb.lib.validation.constants.general import calibration_class_column_name, calibration_variant_column_name
28+
from mavedb.lib.validation.dataframe.calibration import validate_and_standardize_calibration_classes_dataframe
2429
from mavedb.models.score_calibration import ScoreCalibration
2530
from mavedb.models.score_set import ScoreSet
2631
from mavedb.view_models import score_calibration
@@ -29,11 +34,22 @@
2934

3035
router = APIRouter(
3136
prefix="/api/v1/score-calibrations",
32-
tags=["score-calibrations"],
37+
tags=["Score Calibrations"],
3338
responses={404: {"description": "Not found"}},
3439
route_class=LoggedRoute,
3540
)
3641

42+
# Create dependency loaders for flexible JSON/form parsing
43+
calibration_create_loader = json_or_form_loader(
44+
score_calibration.ScoreCalibrationCreate,
45+
field_name="calibration_json",
46+
)
47+
48+
calibration_modify_loader = json_or_form_loader(
49+
score_calibration.ScoreCalibrationModify,
50+
field_name="calibration_json",
51+
)
52+
3753

3854
@router.get(
3955
"/{urn}",
@@ -162,19 +178,95 @@ async def get_primary_score_calibrations_for_score_set(
162178
@router.post(
163179
"/",
164180
response_model=score_calibration.ScoreCalibrationWithScoreSetUrn,
165-
responses={404: {}},
181+
responses={404: {}, 422: {"description": "Validation Error"}},
182+
openapi_extra={
183+
"requestBody": {
184+
"content": {
185+
"application/json": {
186+
"schema": {"$ref": "#/components/schemas/ScoreCalibrationCreate"},
187+
},
188+
"multipart/form-data": {
189+
"schema": {
190+
"type": "object",
191+
"properties": {
192+
"calibration_json": {
193+
"type": "string",
194+
"description": "JSON string containing the calibration data",
195+
"example": '{"score_set_urn":"urn:mavedb:0000000X-X-X","title":"My Calibration","description":"Functional score calibration","baseline_score":1.0}',
196+
},
197+
"classes_file": {
198+
"type": "string",
199+
"format": "binary",
200+
"description": "CSV file containing variant classifications",
201+
},
202+
},
203+
}
204+
},
205+
},
206+
"description": "Score calibration data. Can be sent as JSON body or multipart form data",
207+
}
208+
},
166209
)
167210
async def create_score_calibration_route(
168211
*,
169-
calibration: score_calibration.ScoreCalibrationCreate,
212+
calibration: score_calibration.ScoreCalibrationCreate = Depends(calibration_create_loader),
213+
classes_file: Optional[UploadFile] = File(
214+
None,
215+
description=f"CSV file containing variant classifications. This file must contain two columns: '{calibration_variant_column_name}' and '{calibration_class_column_name}'.",
216+
),
170217
db: Session = Depends(deps.get_db),
171218
user_data: UserData = Depends(require_current_user),
172219
) -> ScoreCalibration:
173220
"""
174221
Create a new score calibration.
175222
176-
The score set URN must be provided to associate the calibration with an existing score set.
177-
The user must have write permission on the associated score set.
223+
This endpoint supports two different request formats to accommodate various client needs:
224+
225+
## Method 1: JSON Request Body (application/json)
226+
Send calibration data as a standard JSON request body. This method is ideal for
227+
creating calibrations without file uploads.
228+
229+
**Content-Type**: `application/json`
230+
231+
**Example**:
232+
```json
233+
{
234+
"score_set_urn": "urn:mavedb:0000000X-X-X",
235+
"title": "My Calibration",
236+
"description": "Functional score calibration",
237+
"baseline_score": 1.0
238+
}
239+
```
240+
241+
## Method 2: Multipart Form Data (multipart/form-data)
242+
Send calibration data as JSON in a form field, optionally with file uploads.
243+
This method is required when uploading classification files.
244+
245+
**Content-Type**: `multipart/form-data`
246+
247+
**Form Fields**:
248+
- `calibration_json` (string, required): JSON string containing the calibration data
249+
- `classes_file` (file, optional): CSV file containing variant classifications
250+
251+
**Example**:
252+
```bash
253+
curl -X POST "/api/v1/score-calibrations/" \\
254+
-H "Authorization: Bearer your-token" \\
255+
-F 'calibration_json={"score_set_urn":"urn:mavedb:0000000X-X-X","title":"My Calibration","description":"Functional score calibration","baseline_score":"1.0"}' \\
256+
-F 'classes_file=@variant_classes.csv'
257+
```
258+
259+
## Requirements
260+
- The score set URN must be provided to associate the calibration with an existing score set
261+
- User must have write permission on the associated score set
262+
- If uploading a classes_file, it must be a valid CSV with variant classification data
263+
264+
## File Upload Details
265+
The `classes_file` parameter accepts CSV files containing variant classification data.
266+
The file should have appropriate headers and contain columns for variant urns and class names.
267+
268+
## Response
269+
Returns the created score calibration with its generated URN and associated score set information.
178270
"""
179271
if not calibration.score_set_urn:
180272
raise HTTPException(status_code=422, detail="score_set_urn must be provided to create a score calibration.")
@@ -190,7 +282,22 @@ async def create_score_calibration_route(
190282
# permission to update the score set itself.
191283
assert_permission(user_data, score_set, Action.UPDATE)
192284

193-
created_calibration = await create_score_calibration_in_score_set(db, calibration, user_data.user)
285+
if classes_file:
286+
try:
287+
classes_df = csv_data_to_df(classes_file.file, induce_hgvs_cols=False)
288+
except UnicodeDecodeError as e:
289+
raise HTTPException(
290+
status_code=400, detail=f"Error decoding file: {e}. Ensure the file has correct values."
291+
)
292+
293+
standardized_classes_df = validate_and_standardize_calibration_classes_dataframe(
294+
db, score_set, calibration, classes_df
295+
)
296+
variant_classes = variant_classification_df_to_dict(standardized_classes_df)
297+
298+
created_calibration = await create_score_calibration_in_score_set(
299+
db, calibration, user_data.user, variant_classes if classes_file else None
300+
)
194301

195302
db.commit()
196303
db.refresh(created_calibration)
@@ -201,17 +308,99 @@ async def create_score_calibration_route(
201308
@router.put(
202309
"/{urn}",
203310
response_model=score_calibration.ScoreCalibrationWithScoreSetUrn,
204-
responses={404: {}},
311+
responses={404: {}, 422: {"description": "Validation Error"}},
312+
openapi_extra={
313+
"requestBody": {
314+
"content": {
315+
"application/json": {
316+
"schema": {"$ref": "#/components/schemas/ScoreCalibrationModify"},
317+
},
318+
"multipart/form-data": {
319+
"schema": {
320+
"type": "object",
321+
"properties": {
322+
"calibration_json": {
323+
"type": "string",
324+
"description": "JSON string containing the calibration update data",
325+
"example": '{"title":"Updated Calibration","description":"Updated description","baseline_score":2.0}',
326+
},
327+
"classes_file": {
328+
"type": "string",
329+
"format": "binary",
330+
"description": "CSV file containing updated variant classifications",
331+
},
332+
},
333+
}
334+
},
335+
},
336+
"description": "Score calibration update data. Can be sent as JSON body or multipart form data",
337+
}
338+
},
205339
)
206340
async def modify_score_calibration_route(
207341
*,
208342
urn: str,
209-
calibration_update: score_calibration.ScoreCalibrationModify,
343+
calibration_update: score_calibration.ScoreCalibrationModify = Depends(calibration_modify_loader),
344+
classes_file: Optional[UploadFile] = File(
345+
None,
346+
description=f"CSV file containing variant classifications. This file must contain two columns: '{calibration_variant_column_name}' and '{calibration_class_column_name}'.",
347+
),
210348
db: Session = Depends(deps.get_db),
211349
user_data: UserData = Depends(require_current_user),
212350
) -> ScoreCalibration:
213351
"""
214352
Modify an existing score calibration by its URN.
353+
354+
This endpoint supports two different request formats to accommodate various client needs:
355+
356+
## Method 1: JSON Request Body (application/json)
357+
Send calibration update data as a standard JSON request body. This method is ideal for
358+
modifying calibrations without file uploads.
359+
360+
**Content-Type**: `application/json`
361+
362+
**Example**:
363+
```json
364+
{
365+
"score_set_urn": "urn:mavedb:0000000X-X-X",
366+
"title": "Updated Calibration Title",
367+
"description": "Updated functional score calibration",
368+
"baseline_score": 1.0
369+
}
370+
```
371+
372+
## Method 2: Multipart Form Data (multipart/form-data)
373+
Send calibration update data as JSON in a form field, optionally with file uploads.
374+
This method is required when uploading new classification files.
375+
376+
**Content-Type**: `multipart/form-data`
377+
378+
**Form Fields**:
379+
- `calibration_json` (string, required): JSON string containing the calibration update data
380+
- `classes_file` (file, optional): CSV file containing updated variant classifications
381+
382+
**Example**:
383+
```bash
384+
curl -X PUT "/api/v1/score-calibrations/{urn}" \\
385+
-H "Authorization: Bearer your-token" \\
386+
-F 'calibration_json={"score_set_urn":"urn:mavedb:0000000X-X-X","title":"My Calibration","description":"Functional score calibration","baseline_score":"1.0"}' \\
387+
-F 'classes_file=@updated_variant_classes.csv'
388+
```
389+
390+
## Requirements
391+
- User must have update permission on the calibration
392+
- If changing the score_set_urn, user must have permission on the new score set
393+
- All fields in the update are optional - only provided fields will be modified
394+
395+
## File Upload Details
396+
The `classes_file` parameter accepts CSV files containing updated variant classification data.
397+
If provided, this will replace the existing classification data for the calibration.
398+
The file should have appropriate headers and follow the expected format for variant
399+
classifications within the associated score set.
400+
401+
## Response
402+
Returns the updated score calibration with all modifications applied and any new
403+
classification data from the uploaded file.
215404
"""
216405
save_to_logging_context({"requested_resource": urn})
217406

@@ -241,7 +430,22 @@ async def modify_score_calibration_route(
241430

242431
assert_permission(user_data, item, Action.UPDATE)
243432

244-
updated_calibration = await modify_score_calibration(db, item, calibration_update, user_data.user)
433+
if classes_file:
434+
try:
435+
classes_df = csv_data_to_df(classes_file.file, induce_hgvs_cols=False)
436+
except UnicodeDecodeError as e:
437+
raise HTTPException(
438+
status_code=400, detail=f"Error decoding file: {e}. Ensure the file has correct values."
439+
)
440+
441+
standardized_classes_df = validate_and_standardize_calibration_classes_dataframe(
442+
db, score_set, calibration_update, classes_df
443+
)
444+
variant_classes = variant_classification_df_to_dict(standardized_classes_df)
445+
446+
updated_calibration = await modify_score_calibration(
447+
db, item, calibration_update, user_data.user, variant_classes if classes_file else None
448+
)
245449

246450
db.commit()
247451
db.refresh(updated_calibration)

0 commit comments

Comments
 (0)