diff --git a/store/backend/neurostore/resources/base.py b/store/backend/neurostore/resources/base.py index f50bc901..1bf7451b 100644 --- a/store/backend/neurostore/resources/base.py +++ b/store/backend/neurostore/resources/base.py @@ -2,6 +2,7 @@ Base Classes/functions for constructing views """ +import json import re from connexion.context import context @@ -11,6 +12,7 @@ abort_permission, abort_validation, abort_not_found, + abort_unprocessable, ) from psycopg2 import errors @@ -45,6 +47,12 @@ from . import data as viewdata +@parser.error_handler +def handle_parser_error(err, req, schema, *, error_status_code, error_headers): + detail = json.dumps(err.messages) + abort_unprocessable(f"input does not conform to specification: {detail}") + + def create_user(): from auth0.v3.authentication.users import Users @@ -128,10 +136,13 @@ def update_annotations(self, annotations): create_annotation_analyses = [] for result in results: if result.analysis_id and not result.annotation_analysis_id: + note_payload = result.note or self._build_default_note(result.note_keys) + if note_payload is None: + note_payload = {} params = { "analysis_id": result.analysis_id, "annotation_id": result.id, - "note": result.note or {}, + "note": note_payload, "user_id": result.user_id, "study_id": result.study_id, "studyset_id": result.studyset_id, @@ -215,6 +226,12 @@ def update_base_studies(self, base_studies): def eager_load(self, q, args): return q + @staticmethod + def _build_default_note(note_keys): + if not note_keys: + return None + return {key: None for key in note_keys.keys()} + def db_validation(self, record, data): """ Custom validation for database constraints. diff --git a/store/backend/neurostore/schemas/data.py b/store/backend/neurostore/schemas/data.py index 273b002c..4611b82c 100644 --- a/store/backend/neurostore/schemas/data.py +++ b/store/backend/neurostore/schemas/data.py @@ -6,6 +6,7 @@ pre_dump, pre_load, post_load, + validates_schema, EXCLUDE, ValidationError, ) @@ -662,6 +663,19 @@ def add_id(self, data, **kwargs): data["studyset"] = {"id": data.pop("studyset_id")} return data + @validates_schema + def validate_notes(self, data, **kwargs): + notes = data.get("annotation_analyses") or [] + invalid = {} + + for idx, note in enumerate(notes): + note_payload = note.get("note") if isinstance(note, dict) else None + if not isinstance(note_payload, dict) or len(note_payload) == 0: + invalid[idx] = ["note must include at least one field"] + + if invalid: + raise ValidationError({"notes": invalid}) + class BaseSnapshot(object): def __init__(self): diff --git a/store/backend/neurostore/tests/api/test_annotations.py b/store/backend/neurostore/tests/api/test_annotations.py index 2d3f898d..45274c8e 100644 --- a/store/backend/neurostore/tests/api/test_annotations.py +++ b/store/backend/neurostore/tests/api/test_annotations.py @@ -18,6 +18,48 @@ def test_post_blank_annotation(auth_client, ingest_neurosynth, session): assert annot.annotation_analyses[0].user_id == annot.user_id +def test_blank_annotation_populates_note_fields(auth_client, ingest_neurosynth, session): + dset = Studyset.query.first() + note_keys = {"included": "boolean", "quality": "string"} + payload = { + "studyset": dset.id, + "note_keys": note_keys, + "name": "with defaults", + } + + resp = auth_client.post("/api/annotations/", data=payload) + assert resp.status_code == 200 + + for note in resp.json()["notes"]: + assert set(note["note"].keys()) == set(note_keys.keys()) + assert all(value is None for value in note["note"].values()) + + +def test_annotation_rejects_empty_note(auth_client, ingest_neurosynth, session): + dset = Studyset.query.first() + study = dset.studies[0] + analysis = study.analyses[0] + + payload = { + "studyset": dset.id, + "notes": [ + { + "study": study.id, + "analysis": analysis.id, + "note": {}, + } + ], + "note_keys": {"included": "boolean"}, + "name": "invalid annotation", + } + + resp = auth_client.post("/api/annotations/", data=payload) + + assert resp.status_code == 422 + error = resp.json() + assert "note must include at least one field" in error["detail"] + + def test_post_annotation(auth_client, ingest_neurosynth, session): dset = Studyset.query.first() # y for x in non_flat for y in x