From bcac0a3ca374b0ce4d7a308dbf545b155efab0dd Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Fri, 15 Nov 2024 16:56:58 +0100 Subject: [PATCH 01/49] start building feedback algorithm --- mondey_backend/src/mondey_backend/feedback.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 mondey_backend/src/mondey_backend/feedback.py diff --git a/mondey_backend/src/mondey_backend/feedback.py b/mondey_backend/src/mondey_backend/feedback.py new file mode 100644 index 00000000..38f521bb --- /dev/null +++ b/mondey_backend/src/mondey_backend/feedback.py @@ -0,0 +1,28 @@ +from mondey_backend.models.milestones import MilestoneAnswer +from mondey_backend.models.milestones import MilestoneAnswerSession +from mondey_backend.models.milestones import MilestoneGroup + + +def calculate_current_child_score(answers: dict[int, MilestoneAnswer]): + score = 0 + for _, answer in answers.items(): + score += answer + return score / len(answers) + + +def get_feedback_score( + milestoneGroup: MilestoneGroup, + answerssession: MilestoneAnswerSession, + avg_stat: float, + sigma_stat: float, +): + avg_child = calculate_current_child_score( + {m.id: answerssession.answers[m.id] for m in milestoneGroup.milestones} + ) + + if avg_stat - 2 * sigma_stat <= avg_child <= avg_stat - sigma_stat: + return -1 + elif avg_stat - sigma_stat <= avg_child <= avg_stat: + return 0 + else: + return 1 From 2e1c3d7d4e07f167b1070fe19afa60094b7f48b8 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 18 Nov 2024 14:47:14 +0100 Subject: [PATCH 02/49] add feedback algorithm prototype --- mondey_backend/src/mondey_backend/feedback.py | 28 ---- .../src/mondey_backend/models/milestones.py | 9 ++ .../src/mondey_backend/routers/scores.py | 137 ++++++++++++++++++ .../src/mondey_backend/routers/users.py | 13 ++ .../src/mondey_backend/routers/utils.py | 66 +++++++-- 5 files changed, 214 insertions(+), 39 deletions(-) delete mode 100644 mondey_backend/src/mondey_backend/feedback.py create mode 100644 mondey_backend/src/mondey_backend/routers/scores.py diff --git a/mondey_backend/src/mondey_backend/feedback.py b/mondey_backend/src/mondey_backend/feedback.py deleted file mode 100644 index 38f521bb..00000000 --- a/mondey_backend/src/mondey_backend/feedback.py +++ /dev/null @@ -1,28 +0,0 @@ -from mondey_backend.models.milestones import MilestoneAnswer -from mondey_backend.models.milestones import MilestoneAnswerSession -from mondey_backend.models.milestones import MilestoneGroup - - -def calculate_current_child_score(answers: dict[int, MilestoneAnswer]): - score = 0 - for _, answer in answers.items(): - score += answer - return score / len(answers) - - -def get_feedback_score( - milestoneGroup: MilestoneGroup, - answerssession: MilestoneAnswerSession, - avg_stat: float, - sigma_stat: float, -): - avg_child = calculate_current_child_score( - {m.id: answerssession.answers[m.id] for m in milestoneGroup.milestones} - ) - - if avg_stat - 2 * sigma_stat <= avg_child <= avg_stat - sigma_stat: - return -1 - elif avg_stat - sigma_stat <= avg_child <= avg_stat: - return 0 - else: - return 1 diff --git a/mondey_backend/src/mondey_backend/models/milestones.py b/mondey_backend/src/mondey_backend/models/milestones.py index 74d909c3..7fbb3c75 100644 --- a/mondey_backend/src/mondey_backend/models/milestones.py +++ b/mondey_backend/src/mondey_backend/models/milestones.py @@ -174,11 +174,20 @@ class MilestoneAnswerSessionPublic(SQLModel): class MilestoneAgeScore(BaseModel): + milestone_id: int age_months: int avg_score: float + sigma_score: float expected_score: float class MilestoneAgeScores(BaseModel): scores: list[MilestoneAgeScore] expected_age: int + + +class MilestoneGroupAgeScore(BaseModel): + age_months: int + group_id: int | None + avg_score: float + sigma_score: float diff --git a/mondey_backend/src/mondey_backend/routers/scores.py b/mondey_backend/src/mondey_backend/routers/scores.py new file mode 100644 index 00000000..99c79c65 --- /dev/null +++ b/mondey_backend/src/mondey_backend/routers/scores.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import numpy as np + +from ..dependencies import SessionDep +from ..models.milestones import MilestoneAgeScore +from ..models.milestones import MilestoneAnswerSession +from ..models.milestones import MilestoneGroup +from ..models.milestones import MilestoneGroupAgeScore +from .utils import calculate_milestone_age_scores +from .utils import calculate_milestone_group_age_scores +from .utils import get + + +def compute_feedback_simple( + stat: MilestoneAgeScore | MilestoneGroupAgeScore, score: float +) -> int: + """ + Compute trafficlight feedback. Replace this function with your own if you + want to change the feedback logic. + Parameters + ---------- + stat : MilestoneAgeScore + Struct containing the average and standard deviation of the scores for a single milestone + value : float + + Returns + ------- + int + -1 if score <= avg - 2 * sigma (trafficlight: red) + 0 if avg - 2 * sigma < score <= avg - sigma (trafficlight: yellow) + 1 if score > avg - sigma (trafficlight: green) + """ + lim_lower = stat.avg_score - 2 * stat.sigma_score + lim_upper = stat.avg_score - stat.sigma_score + + def leq(val: float, lim: float) -> bool: + return val < lim or np.isclose(val, lim) + + def geq(val: float, lim: float) -> bool: + return val > lim or np.isclose(val, lim) + + if leq(score, lim_lower): + return -1 # red + elif score > lim_lower and leq(score, lim_upper): + return 0 # yellow + else: + return 1 # green + + +def compute_feedback_for_milestonegroup( + session: SessionDep, + milestonegroup_id: int, + answer_session: MilestoneAnswerSession, + age: int, + age_limit_low: int = 6, + age_limit_high: int = 6, + with_detailed: bool = False, + mg_score=None, +) -> tuple[int, dict[int, int]] | tuple[int, None]: + """ + Compute trafficlight feedback for milestonegroup. + + Parameters + ---------- + session : SessionDep + Current database session + milestonegroup_id : int + Relevant milestone group to compute the feedback for + answer_session : MilestoneAnswerSession + Current answer session. Child's score that is to be evaluated is derived from this as mean(answers) + age : int + child age + age_limit_low : int, optional + number of months subtracted from age to get lower limit for relevant milestones. Lower limit will be age - age_limit_low, by default 6 + age_limit_high : int, optional + number of months added to age to get upper limit for relevant milestones. Upper limit will be age - age_limit_upper,, by default 6 + with_detailed : bool, optional + Whether a detailed feedback for each milestone at the current age is desired or not, by default False + mg_score : _type_, optional + Statistics over the milestonegroup for the age interval [age-age_limit_low, age+age_limit_high). Will be computed if None. by default None + + Returns + ------- + int | tuple[int, dict[int, int]] + the trafficlight feedback for the milestone group. + -1 if child score <= group_avg - 2 * group_sigma (trafficlight: red) + 0 if group_avg - 2 * group_sigma < score <= group_avg - group_sigma (trafficlight: yellow) + 1 if score > group_avg - group_sigma (trafficlight: green) + + If with_detailed is True, a tuple is returned with the first element being the total feedback and the second element being a dictionary with the feedback for each milestone in the milestonegroup. + """ + age_lower = age - age_limit_low + age_upper = age + age_limit_high + milestonegroup = get(session, MilestoneGroup, milestonegroup_id) + # compute value for child + mean_score_child = np.mean( + [ + answer_session.answers[milestone.id].answer # type: ignore + for milestone in milestonegroup.milestones + if age_lower <= milestone.expected_age_months <= age_upper + ] + ) + + # compute milestone group statistics: + if mg_score is None: + mg_score = calculate_milestone_group_age_scores( + session, + milestonegroup.id, # type: ignore + age, + age_lower, + age_upper, + ) + # compute feedback for the milestonegroup as a whole. + total_feedback = compute_feedback_simple( + mg_score, + mean_score_child, + ) + + if with_detailed: + # compute individual feedback for each milestone for the current time point + detailed_feedback: dict[int, int] = {} + for milestone in milestonegroup.milestones: + if age_lower <= age < age_upper: + child_answer = answer_session.answers[milestone.id].answer # type: ignore + + statistics = calculate_milestone_age_scores(session, milestone.id) # type: ignore + + feedback = compute_feedback_simple( + statistics.scores[age], + child_answer, # type: ignore + ) + detailed_feedback[milestone.id] = feedback # type: ignore + + return total_feedback, detailed_feedback + else: + return total_feedback, None diff --git a/mondey_backend/src/mondey_backend/routers/users.py b/mondey_backend/src/mondey_backend/routers/users.py index c3c685aa..6fa709d4 100644 --- a/mondey_backend/src/mondey_backend/routers/users.py +++ b/mondey_backend/src/mondey_backend/routers/users.py @@ -235,4 +235,17 @@ def update_current_child_answers( return {"ok": True} + @router.get( + "/feedback/{child_id}/{milestonegroup_id}", + response_model=tuple[int, dict[int, int] | None], + ) + def get_feedback_for_milestoneGroup( + session: SessionDep, + current_active_user: CurrentActiveUserDep, + child_id: int, + milestonegroup_id: int, + ) -> tuple[int, dict[int, int] | None]: + # TODO: Implement this endpoint + return 0, {0: -1} + return router diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index 40c67765..cfdd059c 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -24,6 +24,7 @@ from ..models.milestones import MilestoneAnswerSession from ..models.milestones import MilestoneGroup from ..models.milestones import MilestoneGroupAdmin +from ..models.milestones import MilestoneGroupAgeScore from ..models.milestones import MilestoneGroupText from ..models.milestones import MilestoneText from ..models.questions import ChildQuestion @@ -184,21 +185,29 @@ def _get_expected_age_from_scores(scores: np.ndarray) -> int: def _get_average_scores_by_age( answers: Sequence[MilestoneAnswer], child_ages: dict[int, int] -) -> np.ndarray: +) -> tuple[np.ndarray, np.ndarray]: max_age_months = 72 - scores = np.zeros(max_age_months + 1) - counts = np.zeros_like(scores) + avg_scores = np.zeros(max_age_months + 1) + sigma_scores = np.zeros(max_age_months + 1) + counts = np.zeros_like(avg_scores) for answer in answers: age = child_ages[answer.answer_session_id] # type: ignore # convert 0-3 answer index to 1-4 score - scores[age] += answer.answer + 1 + avg_scores[age] += answer.answer + 1 counts[age] += 1 + + for answer in answers: + age = child_ages[answer.answer_session_id] # type: ignore + sigma_scores[age] += (answer.answer + 1 - avg_scores[age]) ** 2 + # divide each score by the number of answers with np.errstate(invalid="ignore"): - scores /= counts + avg_scores /= counts + sigma_scores = np.sqrt(sigma_scores / np.max(counts - 1, 0)) # replace NaNs (due to zero counts) with zeros - scores = np.nan_to_num(scores) - return scores + avg = np.nan_to_num(avg_scores) + sigma = np.nan_to_num(sigma_scores) + return avg, sigma def calculate_milestone_age_scores( @@ -208,21 +217,56 @@ def calculate_milestone_age_scores( answers = session.exec( select(MilestoneAnswer).where(col(MilestoneAnswer.milestone_id) == milestone_id) ).all() - scores = _get_average_scores_by_age(answers, child_ages) - expected_age = _get_expected_age_from_scores(scores) + avg, sigma = _get_average_scores_by_age(answers, child_ages) + expected_age = _get_expected_age_from_scores(avg) return MilestoneAgeScores( expected_age=expected_age, scores=[ MilestoneAgeScore( + milestone_id=milestone_id, age_months=age, - avg_score=score, + avg_score=avg[age], + sigma_score=sigma[age], expected_score=(4 if age >= expected_age else 1), ) - for age, score in enumerate(scores) + for age in range(0, len(avg)) ], ) +def calculate_milestone_group_age_scores( + session: SessionDep, + milestone_group_id: int, + age: int, + age_lower: int, + age_upper: int, +) -> MilestoneGroupAgeScore: + milestonegroup = get(session, MilestoneGroup, milestone_group_id) + + all_answers = [] + for milestone in milestonegroup.milestones: + answers = [ + answer.answer + for answer in session.exec( + select(MilestoneAnswer).where( + col(MilestoneAnswer.milestone_id) == milestone.id + and age_lower <= milestone.expected_age_months <= age_upper + ) + ).all() + ] + all_answers.extend(answers) + + avg_group = np.nan_to_num(np.mean(all_answers)) + sigma_group = np.nan_to_num(np.std(all_answers)) + mg_score = MilestoneGroupAgeScore( + age_months=age, + group_id=milestonegroup.id, + avg_score=avg_group, + sigma_score=sigma_group, + ) + return mg_score + + def child_image_path(child_id: int | None) -> pathlib.Path: return pathlib.Path(f"{app_settings.PRIVATE_FILES_PATH}/children/{child_id}.jpg") From b8aa3bcadb433dbbc7cf953580ea5d23877e59b7 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 18 Nov 2024 15:19:00 +0100 Subject: [PATCH 03/49] start writing tests --- .../src/mondey_backend/routers/scores.py | 14 ++++- mondey_backend/tests/utils/test_scores.py | 58 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 mondey_backend/tests/utils/test_scores.py diff --git a/mondey_backend/src/mondey_backend/routers/scores.py b/mondey_backend/src/mondey_backend/routers/scores.py index 99c79c65..b5d93305 100644 --- a/mondey_backend/src/mondey_backend/routers/scores.py +++ b/mondey_backend/src/mondey_backend/routers/scores.py @@ -31,8 +31,6 @@ def compute_feedback_simple( 0 if avg - 2 * sigma < score <= avg - sigma (trafficlight: yellow) 1 if score > avg - sigma (trafficlight: green) """ - lim_lower = stat.avg_score - 2 * stat.sigma_score - lim_upper = stat.avg_score - stat.sigma_score def leq(val: float, lim: float) -> bool: return val < lim or np.isclose(val, lim) @@ -40,6 +38,18 @@ def leq(val: float, lim: float) -> bool: def geq(val: float, lim: float) -> bool: return val > lim or np.isclose(val, lim) + if stat.sigma_score < 1e-2: + # README: this relies on the score being, 1,2,3,4 integers + # Check again what client wants + lim_lower = stat.avg_score - 2 + + lim_upper = stat.avg_score - 1 + + else: + lim_lower = stat.avg_score - 2 * stat.sigma_score + + lim_upper = stat.avg_score - stat.sigma_score + if leq(score, lim_lower): return -1 # red elif score > lim_lower and leq(score, lim_upper): diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_scores.py new file mode 100644 index 00000000..6cc48aa0 --- /dev/null +++ b/mondey_backend/tests/utils/test_scores.py @@ -0,0 +1,58 @@ +from mondey_backend.models.milestones import MilestoneAgeScore +from mondey_backend.routers.scores import compute_feedback_simple + + +def test_compute_milestone_statistics(): + dummy_scores = MilestoneAgeScore( + milestone_id=1, age_months=8, avg_score=2.0, sigma_score=0.8, expected_score=1.0 + ) + score = 0 + assert compute_feedback_simple(dummy_scores, score) == -1 + + score = 1 + assert compute_feedback_simple(dummy_scores, score) == 0 + + score = 3 + assert compute_feedback_simple(dummy_scores, score) == 1 + + +def test_compute_milestone_statistics_bad_data(): + dummy_scores = MilestoneAgeScore( + milestone_id=1, age_months=8, avg_score=2.0, sigma_score=0, expected_score=1.0 + ) + score = 0 + assert compute_feedback_simple(dummy_scores, score) == -1 + + score = 1 + assert compute_feedback_simple(dummy_scores, score) == 0 + + score = 3 + assert compute_feedback_simple(dummy_scores, score) == 1 + + dummy_scores.avg_score = 0 + score = 0 + assert compute_feedback_simple(dummy_scores, score) == 1 + + score = 1 + assert compute_feedback_simple(dummy_scores, score) == 1 + + score = 2 + assert compute_feedback_simple(dummy_scores, score) == 1 + + +# def test_compute_milestonegroup_statistics(): +# # calculate_milestone_group_age_scores + +# assert 3 == 6 + +# def test_compute_milestonegroup_statistics_bad_data(): +# # calculate_milestone_group_age_scores +# assert 3 == 6 + +# def test_compute_feedback_simple(): +# # compute_feedback_simple +# assert 3 == 6 + +# def test_compute_milestonegroup_feedback(): +# # compute_feedback_for_milestonegroup +# assert 3 == 6 From 648cc57006f1667c9b10b457eb38e9eb197e2c24 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 18 Nov 2024 16:37:28 +0100 Subject: [PATCH 04/49] work on tests for backend --- .../src/mondey_backend/routers/utils.py | 9 ++- mondey_backend/tests/conftest.py | 2 + mondey_backend/tests/utils/test_scores.py | 62 +++++++++++++++++-- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index cfdd059c..c6b9f179 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -190,23 +190,28 @@ def _get_average_scores_by_age( avg_scores = np.zeros(max_age_months + 1) sigma_scores = np.zeros(max_age_months + 1) counts = np.zeros_like(avg_scores) + for answer in answers: age = child_ages[answer.answer_session_id] # type: ignore # convert 0-3 answer index to 1-4 score avg_scores[age] += answer.answer + 1 counts[age] += 1 + # divide each score by the number of answers + with np.errstate(invalid="ignore"): + avg_scores /= counts + for answer in answers: age = child_ages[answer.answer_session_id] # type: ignore sigma_scores[age] += (answer.answer + 1 - avg_scores[age]) ** 2 - # divide each score by the number of answers with np.errstate(invalid="ignore"): - avg_scores /= counts sigma_scores = np.sqrt(sigma_scores / np.max(counts - 1, 0)) + # replace NaNs (due to zero counts) with zeros avg = np.nan_to_num(avg_scores) sigma = np.nan_to_num(sigma_scores) + return avg, sigma diff --git a/mondey_backend/tests/conftest.py b/mondey_backend/tests/conftest.py index 4e3c2ded..4c663f0d 100644 --- a/mondey_backend/tests/conftest.py +++ b/mondey_backend/tests/conftest.py @@ -202,8 +202,10 @@ def session(children: list[dict]): session.add(MilestoneImage(milestone_id=1, filename="m2.jpg", approved=True)) session.add(MilestoneImage(milestone_id=2, filename="m3.jpg", approved=True)) session.commit() + for child, user_id in zip(children, [3, 3, 1], strict=False): session.add(Child.model_validate(child, update={"user_id": user_id})) + today = datetime.datetime.today() last_month = today - datetime.timedelta(days=30) # add an (expired) milestone answer session for child 1 / user (id 3) with 2 answers diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_scores.py index 6cc48aa0..88449894 100644 --- a/mondey_backend/tests/utils/test_scores.py +++ b/mondey_backend/tests/utils/test_scores.py @@ -1,8 +1,15 @@ +import numpy as np +from sqlalchemy import select + from mondey_backend.models.milestones import MilestoneAgeScore +from mondey_backend.models.milestones import MilestoneAnswer from mondey_backend.routers.scores import compute_feedback_simple +from mondey_backend.routers.utils import _get_answer_session_child_ages_in_months +from mondey_backend.routers.utils import _get_average_scores_by_age +from mondey_backend.routers.utils import calculate_milestone_age_scores -def test_compute_milestone_statistics(): +def test_compute_feedback_simple(): dummy_scores = MilestoneAgeScore( milestone_id=1, age_months=8, avg_score=2.0, sigma_score=0.8, expected_score=1.0 ) @@ -16,7 +23,7 @@ def test_compute_milestone_statistics(): assert compute_feedback_simple(dummy_scores, score) == 1 -def test_compute_milestone_statistics_bad_data(): +def test_compute_feedback_simple_bad_data(): dummy_scores = MilestoneAgeScore( milestone_id=1, age_months=8, avg_score=2.0, sigma_score=0, expected_score=1.0 ) @@ -40,6 +47,53 @@ def test_compute_milestone_statistics_bad_data(): assert compute_feedback_simple(dummy_scores, score) == 1 +def test_get_answer_session_child_ages_in_months(session): + child_ages = _get_answer_session_child_ages_in_months(session) + assert len(child_ages) == 3 + assert child_ages[2] == 9 + assert child_ages[1] == 8 + assert child_ages[3] == 42 + + # TODO: check what can go wrong here + + +def test_get_average_scores_by_age(session): + answers = [answer[0] for answer in session.exec(select(MilestoneAnswer)).all()] + child_ages = {1: 5, 2: 3, 3: 8} + + avg, sigma = _get_average_scores_by_age(answers, child_ages) + + assert avg[5] == 1.5 + assert avg[3] == 3.5 + assert avg[8] == 3.0 + + assert sigma[5] == np.std( + [answer.answer + 1 for answer in answers if answer.answer_session_id == 1], + ddof=1, + ) + assert sigma[3] == np.std( + [answer.answer + 1 for answer in answers if answer.answer_session_id == 2], + ddof=1, + ) + assert sigma[8] == np.nan_to_num( + np.std( + [answer.answer + 1 for answer in answers if answer.answer_session_id == 2], + ddof=1, + ) + ) + + +def test_calculate_milestone_age_scores(session): + # calculate_milestone_age_scores + + mscore = calculate_milestone_age_scores(session, 1) + + for m in mscore.scores: + print(m) + + assert 3 == 6 + + # def test_compute_milestonegroup_statistics(): # # calculate_milestone_group_age_scores @@ -49,10 +103,6 @@ def test_compute_milestone_statistics_bad_data(): # # calculate_milestone_group_age_scores # assert 3 == 6 -# def test_compute_feedback_simple(): -# # compute_feedback_simple -# assert 3 == 6 - # def test_compute_milestonegroup_feedback(): # # compute_feedback_for_milestonegroup # assert 3 == 6 From b77e7959f7bab8e0b40f03b672da180d41139f33 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 18 Nov 2024 16:54:40 +0100 Subject: [PATCH 05/49] add test for milestone score --- .../src/mondey_backend/routers/utils.py | 1 + mondey_backend/tests/utils/test_scores.py | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index c6b9f179..98c6bea5 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -222,6 +222,7 @@ def calculate_milestone_age_scores( answers = session.exec( select(MilestoneAnswer).where(col(MilestoneAnswer.milestone_id) == milestone_id) ).all() + avg, sigma = _get_average_scores_by_age(answers, child_ages) expected_age = _get_expected_age_from_scores(avg) return MilestoneAgeScores( diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_scores.py index 88449894..c300a60d 100644 --- a/mondey_backend/tests/utils/test_scores.py +++ b/mondey_backend/tests/utils/test_scores.py @@ -71,13 +71,15 @@ def test_get_average_scores_by_age(session): [answer.answer + 1 for answer in answers if answer.answer_session_id == 1], ddof=1, ) + assert sigma[3] == np.std( [answer.answer + 1 for answer in answers if answer.answer_session_id == 2], ddof=1, ) + assert sigma[8] == np.nan_to_num( np.std( - [answer.answer + 1 for answer in answers if answer.answer_session_id == 2], + [answer.answer + 1 for answer in answers if answer.answer_session_id == 3], ddof=1, ) ) @@ -85,13 +87,16 @@ def test_get_average_scores_by_age(session): def test_calculate_milestone_age_scores(session): # calculate_milestone_age_scores - mscore = calculate_milestone_age_scores(session, 1) - - for m in mscore.scores: - print(m) - - assert 3 == 6 + assert mscore.scores[8].avg_score == 2.0 + assert mscore.scores[8].sigma_score == 0.0 + assert mscore.scores[9].avg_score == 4.0 + assert mscore.scores[9].sigma_score == 0.0 + + for score in mscore.scores: + if score.age_months not in [8, 9]: + assert score.avg_score == 0.0 + assert score.sigma_score == 0.0 # def test_compute_milestonegroup_statistics(): From ab73365ecd70ac4da4d8223c7c75fd3b754d18b0 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 18 Nov 2024 17:00:17 +0100 Subject: [PATCH 06/49] add list of missing stuff to do later --- mondey_backend/tests/utils/test_scores.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_scores.py index c300a60d..ebb31eca 100644 --- a/mondey_backend/tests/utils/test_scores.py +++ b/mondey_backend/tests/utils/test_scores.py @@ -54,7 +54,7 @@ def test_get_answer_session_child_ages_in_months(session): assert child_ages[1] == 8 assert child_ages[3] == 42 - # TODO: check what can go wrong here + # TODO: check edge cases def test_get_average_scores_by_age(session): @@ -84,6 +84,10 @@ def test_get_average_scores_by_age(session): ) ) + # TODO: check edge cases + # - no answers + # - bad child ages + def test_calculate_milestone_age_scores(session): # calculate_milestone_age_scores From baf520dc1f1f1910630a83a75e9f24212cc19a1d Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 18 Nov 2024 17:07:08 +0100 Subject: [PATCH 07/49] add edge case tests --- .../src/mondey_backend/routers/utils.py | 2 ++ mondey_backend/tests/utils/test_scores.py | 21 +++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index 98c6bea5..8fa606f3 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -190,6 +190,8 @@ def _get_average_scores_by_age( avg_scores = np.zeros(max_age_months + 1) sigma_scores = np.zeros(max_age_months + 1) counts = np.zeros_like(avg_scores) + if child_ages == {}: + return avg_scores, sigma_scores for answer in answers: age = child_ages[answer.answer_session_id] # type: ignore diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_scores.py index ebb31eca..c95a4e37 100644 --- a/mondey_backend/tests/utils/test_scores.py +++ b/mondey_backend/tests/utils/test_scores.py @@ -84,14 +84,23 @@ def test_get_average_scores_by_age(session): ) ) - # TODO: check edge cases - # - no answers - # - bad child ages + child_ages = {} # no answer sessions ==> empty child ages + avg, sigma = _get_average_scores_by_age(answers, child_ages) + assert np.all(avg == 0) + assert np.all(sigma == 0) + + child_ages = {1: 5, 2: 3, 3: 8} + answers = [] # no answers ==> empty answers + avg, sigma = _get_average_scores_by_age(answers, child_ages) + assert np.all(avg == 0) + assert np.all(sigma == 0) def test_calculate_milestone_age_scores(session): # calculate_milestone_age_scores mscore = calculate_milestone_age_scores(session, 1) + + # only some are filled assert mscore.scores[8].avg_score == 2.0 assert mscore.scores[8].sigma_score == 0.0 assert mscore.scores[9].avg_score == 4.0 @@ -103,10 +112,10 @@ def test_calculate_milestone_age_scores(session): assert score.sigma_score == 0.0 -# def test_compute_milestonegroup_statistics(): -# # calculate_milestone_group_age_scores +def test_calculate_milestone_group_age_scores(): + # calculate_milestone_group_age_scores + assert 3 == 6 -# assert 3 == 6 # def test_compute_milestonegroup_statistics_bad_data(): # # calculate_milestone_group_age_scores From 28049645ec041bf7c50472d7cebfb21fce8764cc Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 18 Nov 2024 17:17:46 +0100 Subject: [PATCH 08/49] work more on backend tests --- .../src/mondey_backend/routers/utils.py | 2 +- .../utils/{test_scores.py => test_utils.py} | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) rename mondey_backend/tests/utils/{test_scores.py => test_utils.py} (80%) diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index 8fa606f3..6a395343 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -265,7 +265,7 @@ def calculate_milestone_group_age_scores( all_answers.extend(answers) avg_group = np.nan_to_num(np.mean(all_answers)) - sigma_group = np.nan_to_num(np.std(all_answers)) + sigma_group = np.nan_to_num(np.std(all_answers, correction=1)) mg_score = MilestoneGroupAgeScore( age_months=age, group_id=milestonegroup.id, diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_utils.py similarity index 80% rename from mondey_backend/tests/utils/test_scores.py rename to mondey_backend/tests/utils/test_utils.py index c95a4e37..44d7c13d 100644 --- a/mondey_backend/tests/utils/test_scores.py +++ b/mondey_backend/tests/utils/test_utils.py @@ -3,10 +3,12 @@ from mondey_backend.models.milestones import MilestoneAgeScore from mondey_backend.models.milestones import MilestoneAnswer +from mondey_backend.models.milestones import MilestoneGroup from mondey_backend.routers.scores import compute_feedback_simple from mondey_backend.routers.utils import _get_answer_session_child_ages_in_months from mondey_backend.routers.utils import _get_average_scores_by_age from mondey_backend.routers.utils import calculate_milestone_age_scores +from mondey_backend.routers.utils import calculate_milestone_group_age_scores def test_compute_feedback_simple(): @@ -112,8 +114,35 @@ def test_calculate_milestone_age_scores(session): assert score.sigma_score == 0.0 -def test_calculate_milestone_group_age_scores(): +def test_calculate_milestone_group_age_scores(session): + age = 8 + age_lower = 6 + age_upper = 3 + milestone_group_id = 1 + + milestone_group = session.exec( + select(MilestoneGroup).where(MilestoneGroup.id == 1) + ).first() + # calculate_milestone_group_age_scores + answers = [answer[0] for answer in session.exec(select(MilestoneAnswer)).all()] + answers = [ + answer + for answer in answers + if answer.milestone_id in milestone_group.milestones + ] + + score = calculate_milestone_group_age_scores( + session, milestone_group_id, age, age_lower, age_upper + ) + + assert score.age_months == 8 + assert score.milestone_group_id == 1 + assert score.avg_score == 1.5 + assert score.sigma_score == np.std( + [answers.answer], + ddof=1, + ) assert 3 == 6 From 78c168fe9eb68547c83dbd66603c1b5cc990a84e Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Tue, 19 Nov 2024 09:45:51 +0100 Subject: [PATCH 09/49] add tests, allow for presupplied answers --- .../src/mondey_backend/routers/utils.py | 69 +++++++++++++------ mondey_backend/tests/conftest.py | 6 +- mondey_backend/tests/utils/test_utils.py | 62 ++++++++++------- 3 files changed, 89 insertions(+), 48 deletions(-) diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index 6a395343..8f954302 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -193,16 +193,17 @@ def _get_average_scores_by_age( if child_ages == {}: return avg_scores, sigma_scores + # compute average for answer in answers: age = child_ages[answer.answer_session_id] # type: ignore # convert 0-3 answer index to 1-4 score avg_scores[age] += answer.answer + 1 counts[age] += 1 - # divide each score by the number of answers with np.errstate(invalid="ignore"): avg_scores /= counts + # compute standard deviation for answer in answers: age = child_ages[answer.answer_session_id] # type: ignore sigma_scores[age] += (answer.answer + 1 - avg_scores[age]) ** 2 @@ -218,15 +219,24 @@ def _get_average_scores_by_age( def calculate_milestone_age_scores( - session: SessionDep, milestone_id: int + session: SessionDep, + milestone_id: int, + answers: Sequence[MilestoneAnswer] | None = None, ) -> MilestoneAgeScores: child_ages = _get_answer_session_child_ages_in_months(session) - answers = session.exec( - select(MilestoneAnswer).where(col(MilestoneAnswer.milestone_id) == milestone_id) - ).all() + + if answers is None: + answers = session.exec( + select(MilestoneAnswer).where( + col(MilestoneAnswer.milestone_id) == milestone_id + ) + ).all() + else: + answers = [answer for answer in answers if answer.milestone_id == milestone_id] avg, sigma = _get_average_scores_by_age(answers, child_ages) expected_age = _get_expected_age_from_scores(avg) + return MilestoneAgeScores( expected_age=expected_age, scores=[ @@ -246,26 +256,41 @@ def calculate_milestone_group_age_scores( session: SessionDep, milestone_group_id: int, age: int, - age_lower: int, - age_upper: int, + age_lower: int = 6, + age_upper: int = 6, + answers: list[MilestoneAnswer] | None = None, ) -> MilestoneGroupAgeScore: milestonegroup = get(session, MilestoneGroup, milestone_group_id) - all_answers = [] - for milestone in milestonegroup.milestones: - answers = [ - answer.answer - for answer in session.exec( - select(MilestoneAnswer).where( - col(MilestoneAnswer.milestone_id) == milestone.id - and age_lower <= milestone.expected_age_months <= age_upper - ) - ).all() - ] - all_answers.extend(answers) - - avg_group = np.nan_to_num(np.mean(all_answers)) - sigma_group = np.nan_to_num(np.std(all_answers, correction=1)) + if answers is None: + new_answers = [] + for milestone in milestonegroup.milestones: + m_answers = [ + answer.answer + for answer in session.exec( + select(MilestoneAnswer).where( + col(MilestoneAnswer.milestone_id) == milestone.id + and age_lower <= milestone.expected_age_months <= age_upper + ) + ).all() + ] + new_answers.extend(m_answers) + answers = new_answers # type: ignore + else: + new_answers = [] + for milestone in milestonegroup.milestones: + m_answers = [ + a.answer + for a in answers + if a.milestone_id == milestone.id + and age_lower <= milestone.expected_age_months <= age_upper + ] + new_answers.extend(m_answers) + answers = new_answers # type: ignore + + answers = np.array(answers) + 1 # convert 0-3 answer index to 1-4 score + avg_group = np.nan_to_num(np.mean(answers)) + sigma_group = np.nan_to_num(np.std(answers, correction=1)) mg_score = MilestoneGroupAgeScore( age_months=age, group_id=milestonegroup.id, diff --git a/mondey_backend/tests/conftest.py b/mondey_backend/tests/conftest.py index 4c663f0d..87e41a13 100644 --- a/mondey_backend/tests/conftest.py +++ b/mondey_backend/tests/conftest.py @@ -142,7 +142,11 @@ def session(children: list[dict]): for lang_id in lang_ids: session.add(Language(id=lang_id)) # add a milestone group with 3 milestones - session.add(MilestoneGroup(order=2)) + session.add( + MilestoneGroup( + order=2, + ) + ) for lang_id in lang_ids: lbl = f"g1_{lang_id}" session.add( diff --git a/mondey_backend/tests/utils/test_utils.py b/mondey_backend/tests/utils/test_utils.py index 44d7c13d..1dea448e 100644 --- a/mondey_backend/tests/utils/test_utils.py +++ b/mondey_backend/tests/utils/test_utils.py @@ -1,8 +1,9 @@ import numpy as np -from sqlalchemy import select +from sqlmodel import select from mondey_backend.models.milestones import MilestoneAgeScore from mondey_backend.models.milestones import MilestoneAnswer +from mondey_backend.models.milestones import MilestoneAnswerSession from mondey_backend.models.milestones import MilestoneGroup from mondey_backend.routers.scores import compute_feedback_simple from mondey_backend.routers.utils import _get_answer_session_child_ages_in_months @@ -56,11 +57,9 @@ def test_get_answer_session_child_ages_in_months(session): assert child_ages[1] == 8 assert child_ages[3] == 42 - # TODO: check edge cases - def test_get_average_scores_by_age(session): - answers = [answer[0] for answer in session.exec(select(MilestoneAnswer)).all()] + answers = session.exec(select(MilestoneAnswer)).all() child_ages = {1: 5, 2: 3, 3: 8} avg, sigma = _get_average_scores_by_age(answers, child_ages) @@ -113,43 +112,56 @@ def test_calculate_milestone_age_scores(session): assert score.avg_score == 0.0 assert score.sigma_score == 0.0 + # get milestoneanswersession #1 + answersession = session.exec(select(MilestoneAnswerSession)).first() + mscore = calculate_milestone_age_scores( + session, 1, answers=answersession.answers.values() + ) + assert mscore.scores[8].avg_score == 2.0 + assert mscore.scores[8].sigma_score == 0 + + for score in mscore.scores: + if score.age_months not in [8]: + assert score.avg_score == 0.0 + assert score.sigma_score == 0.0 + def test_calculate_milestone_group_age_scores(session): age = 8 age_lower = 6 - age_upper = 3 + age_upper = 11 milestone_group_id = 1 milestone_group = session.exec( select(MilestoneGroup).where(MilestoneGroup.id == 1) ).first() - - # calculate_milestone_group_age_scores - answers = [answer[0] for answer in session.exec(select(MilestoneAnswer)).all()] + milestones = [m.id for m in milestone_group.milestones] answers = [ - answer - for answer in answers - if answer.milestone_id in milestone_group.milestones + a.answer + for a in session.exec(select(MilestoneAnswer)).all() + if a.milestone_id in milestones ] - score = calculate_milestone_group_age_scores( session, milestone_group_id, age, age_lower, age_upper ) - assert score.age_months == 8 - assert score.milestone_group_id == 1 - assert score.avg_score == 1.5 + assert score.group_id == 1 + assert score.avg_score == 2.5 assert score.sigma_score == np.std( - [answers.answer], + answers, ddof=1, ) - assert 3 == 6 - -# def test_compute_milestonegroup_statistics_bad_data(): -# # calculate_milestone_group_age_scores -# assert 3 == 6 - -# def test_compute_milestonegroup_feedback(): -# # compute_feedback_for_milestonegroup -# assert 3 == 6 + answersession = session.exec(select(MilestoneAnswerSession)).first() + answers = [ + a + for a in session.exec(select(MilestoneAnswer)).all() + if a.milestone_id in milestones and a.answer_session_id == answersession.id + ] + score = calculate_milestone_group_age_scores( + session, milestone_group_id, age, age_lower, age_upper, answers=answers + ) + assert score.age_months == 8 + assert score.group_id == 1 + assert score.avg_score == 2.0 + assert score.sigma_score == 0.0 From f4a09e9102d0a4f698aea257589c38e391bd69fe Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Tue, 19 Nov 2024 11:48:14 +0100 Subject: [PATCH 10/49] adjust tests and score functionality --- .../src/mondey_backend/routers/scores.py | 73 ++++++++------- .../src/mondey_backend/routers/utils.py | 43 +++------ mondey_backend/tests/utils/test_scores.py | 89 +++++++++++++++++++ mondey_backend/tests/utils/test_utils.py | 59 +----------- 4 files changed, 147 insertions(+), 117 deletions(-) create mode 100644 mondey_backend/tests/utils/test_scores.py diff --git a/mondey_backend/src/mondey_backend/routers/scores.py b/mondey_backend/src/mondey_backend/routers/scores.py index b5d93305..8057b18d 100644 --- a/mondey_backend/src/mondey_backend/routers/scores.py +++ b/mondey_backend/src/mondey_backend/routers/scores.py @@ -1,5 +1,7 @@ from __future__ import annotations +from enum import Enum + import numpy as np from ..dependencies import SessionDep @@ -12,6 +14,13 @@ from .utils import get +class TrafficLight(Enum): + invalid = -2 + red = -1 + yellow = 0 + green = 1 + + def compute_feedback_simple( stat: MilestoneAgeScore | MilestoneGroupAgeScore, score: float ) -> int: @@ -35,27 +44,25 @@ def compute_feedback_simple( def leq(val: float, lim: float) -> bool: return val < lim or np.isclose(val, lim) - def geq(val: float, lim: float) -> bool: - return val > lim or np.isclose(val, lim) - if stat.sigma_score < 1e-2: - # README: this relies on the score being, 1,2,3,4 integers - # Check again what client wants + # README: This happens when all the scores are the same, so any + # deviation towards lower values can be interpreted as + # underperformance. + # This logic relies on the score being integers, such that when the + # stddev is 0, the avg is an integer + # TODO: Check again what client wants to happen in such cases lim_lower = stat.avg_score - 2 - lim_upper = stat.avg_score - 1 - else: lim_lower = stat.avg_score - 2 * stat.sigma_score - lim_upper = stat.avg_score - stat.sigma_score if leq(score, lim_lower): - return -1 # red + return TrafficLight.red.value elif score > lim_lower and leq(score, lim_upper): - return 0 # yellow + return TrafficLight.yellow.value else: - return 1 # green + return TrafficLight.green.value def compute_feedback_for_milestonegroup( @@ -103,23 +110,29 @@ def compute_feedback_for_milestonegroup( age_lower = age - age_limit_low age_upper = age + age_limit_high milestonegroup = get(session, MilestoneGroup, milestonegroup_id) + + # get the relevant answers for the child + answers = { + k: v + for k, v in answer_session.answers.items() + if k in [m.id for m in milestonegroup.milestones] + } + + if answers == {}: + return TrafficLight.invalid.value, None + # compute value for child - mean_score_child = np.mean( - [ - answer_session.answers[milestone.id].answer # type: ignore - for milestone in milestonegroup.milestones - if age_lower <= milestone.expected_age_months <= age_upper - ] - ) + mean_score_child = np.nan_to_num(np.mean([a.answer for a in answers.values()])) - # compute milestone group statistics: + # compute milestone group statistics over relevant age range. + # this gets all answers that exist for the milestones in the group if mg_score is None: mg_score = calculate_milestone_group_age_scores( session, - milestonegroup.id, # type: ignore + milestonegroup, # type: ignore age, - age_lower, - age_upper, + age_lower=age_lower, + age_upper=age_upper, ) # compute feedback for the milestonegroup as a whole. total_feedback = compute_feedback_simple( @@ -130,17 +143,15 @@ def compute_feedback_for_milestonegroup( if with_detailed: # compute individual feedback for each milestone for the current time point detailed_feedback: dict[int, int] = {} - for milestone in milestonegroup.milestones: - if age_lower <= age < age_upper: - child_answer = answer_session.answers[milestone.id].answer # type: ignore - statistics = calculate_milestone_age_scores(session, milestone.id) # type: ignore + for milestone_id, answer in answers.items(): + statistics = calculate_milestone_age_scores(session, milestone_id) # type: ignore - feedback = compute_feedback_simple( - statistics.scores[age], - child_answer, # type: ignore - ) - detailed_feedback[milestone.id] = feedback # type: ignore + feedback = compute_feedback_simple( + statistics.scores[age], + answer.answer, # type: ignore + ) + detailed_feedback[milestone_id] = feedback # type: ignore return total_feedback, detailed_feedback else: diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index 8f954302..e3f9cf20 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -254,39 +254,23 @@ def calculate_milestone_age_scores( def calculate_milestone_group_age_scores( session: SessionDep, - milestone_group_id: int, + milestonegroup: MilestoneGroup, age: int, age_lower: int = 6, age_upper: int = 6, - answers: list[MilestoneAnswer] | None = None, ) -> MilestoneGroupAgeScore: - milestonegroup = get(session, MilestoneGroup, milestone_group_id) - - if answers is None: - new_answers = [] - for milestone in milestonegroup.milestones: - m_answers = [ - answer.answer - for answer in session.exec( - select(MilestoneAnswer).where( - col(MilestoneAnswer.milestone_id) == milestone.id - and age_lower <= milestone.expected_age_months <= age_upper - ) - ).all() - ] - new_answers.extend(m_answers) - answers = new_answers # type: ignore - else: - new_answers = [] - for milestone in milestonegroup.milestones: - m_answers = [ - a.answer - for a in answers - if a.milestone_id == milestone.id - and age_lower <= milestone.expected_age_months <= age_upper - ] - new_answers.extend(m_answers) - answers = new_answers # type: ignore + answers = [] + for milestone in milestonegroup.milestones: + m_answers = [ + answer.answer + for answer in session.exec( + select(MilestoneAnswer).where( + col(MilestoneAnswer.milestone_id) == milestone.id + and age_lower <= milestone.expected_age_months <= age_upper + ) + ).all() + ] + answers.extend(m_answers) answers = np.array(answers) + 1 # convert 0-3 answer index to 1-4 score avg_group = np.nan_to_num(np.mean(answers)) @@ -297,6 +281,7 @@ def calculate_milestone_group_age_scores( avg_score=avg_group, sigma_score=sigma_group, ) + return mg_score diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_scores.py new file mode 100644 index 00000000..7855c13d --- /dev/null +++ b/mondey_backend/tests/utils/test_scores.py @@ -0,0 +1,89 @@ +from sqlmodel import select + +from mondey_backend.models.milestones import MilestoneAgeScore +from mondey_backend.models.milestones import MilestoneAnswerSession +from mondey_backend.routers.scores import compute_feedback_for_milestonegroup +from mondey_backend.routers.scores import compute_feedback_simple + + +def test_compute_feedback_simple(): + dummy_scores = MilestoneAgeScore( + milestone_id=1, age_months=8, avg_score=2.0, sigma_score=0.8, expected_score=1.0 + ) + score = 0 + assert compute_feedback_simple(dummy_scores, score) == -1 + + score = 1 + assert compute_feedback_simple(dummy_scores, score) == 0 + + score = 3 + assert compute_feedback_simple(dummy_scores, score) == 1 + + +def test_compute_feedback_simple_bad_data(): + dummy_scores = MilestoneAgeScore( + milestone_id=1, age_months=8, avg_score=2.0, sigma_score=0, expected_score=1.0 + ) + score = 0 + assert compute_feedback_simple(dummy_scores, score) == -1 + + score = 1 + assert compute_feedback_simple(dummy_scores, score) == 0 + + score = 3 + assert compute_feedback_simple(dummy_scores, score) == 1 + + dummy_scores.avg_score = 0 + score = 0 + assert compute_feedback_simple(dummy_scores, score) == 1 + + score = 1 + assert compute_feedback_simple(dummy_scores, score) == 1 + + score = 2 + assert compute_feedback_simple(dummy_scores, score) == 1 + + +def test_compute_feedback_for_milestonegroup(session): + answersession = session.exec(select(MilestoneAnswerSession)).first() + + total, detailed = compute_feedback_for_milestonegroup( + session, + 1, + answersession, + 8, + age_limit_low=6, + age_limit_high=6, + with_detailed=True, + ) + assert total == 0 + assert detailed == {1: 0, 2: 0} + + total, detailed = compute_feedback_for_milestonegroup( + session, + 1, + answersession, + 8, + age_limit_low=6, + age_limit_high=6, + with_detailed=False, + ) + + assert total == 0 + assert detailed is None + + +def test_compute_feedback_for_milestonegroup_bad_data(session): + answersession = session.exec(select(MilestoneAnswerSession)).first() + answersession.answers = {} + total, detailed = compute_feedback_for_milestonegroup( + session, + 1, + answersession, + 8, + age_limit_low=6, + age_limit_high=6, + with_detailed=True, + ) + assert total == -2 + assert detailed is None diff --git a/mondey_backend/tests/utils/test_utils.py b/mondey_backend/tests/utils/test_utils.py index 1dea448e..926d02e0 100644 --- a/mondey_backend/tests/utils/test_utils.py +++ b/mondey_backend/tests/utils/test_utils.py @@ -1,55 +1,15 @@ import numpy as np from sqlmodel import select -from mondey_backend.models.milestones import MilestoneAgeScore from mondey_backend.models.milestones import MilestoneAnswer from mondey_backend.models.milestones import MilestoneAnswerSession from mondey_backend.models.milestones import MilestoneGroup -from mondey_backend.routers.scores import compute_feedback_simple from mondey_backend.routers.utils import _get_answer_session_child_ages_in_months from mondey_backend.routers.utils import _get_average_scores_by_age from mondey_backend.routers.utils import calculate_milestone_age_scores from mondey_backend.routers.utils import calculate_milestone_group_age_scores -def test_compute_feedback_simple(): - dummy_scores = MilestoneAgeScore( - milestone_id=1, age_months=8, avg_score=2.0, sigma_score=0.8, expected_score=1.0 - ) - score = 0 - assert compute_feedback_simple(dummy_scores, score) == -1 - - score = 1 - assert compute_feedback_simple(dummy_scores, score) == 0 - - score = 3 - assert compute_feedback_simple(dummy_scores, score) == 1 - - -def test_compute_feedback_simple_bad_data(): - dummy_scores = MilestoneAgeScore( - milestone_id=1, age_months=8, avg_score=2.0, sigma_score=0, expected_score=1.0 - ) - score = 0 - assert compute_feedback_simple(dummy_scores, score) == -1 - - score = 1 - assert compute_feedback_simple(dummy_scores, score) == 0 - - score = 3 - assert compute_feedback_simple(dummy_scores, score) == 1 - - dummy_scores.avg_score = 0 - score = 0 - assert compute_feedback_simple(dummy_scores, score) == 1 - - score = 1 - assert compute_feedback_simple(dummy_scores, score) == 1 - - score = 2 - assert compute_feedback_simple(dummy_scores, score) == 1 - - def test_get_answer_session_child_ages_in_months(session): child_ages = _get_answer_session_child_ages_in_months(session) assert len(child_ages) == 3 @@ -130,7 +90,6 @@ def test_calculate_milestone_group_age_scores(session): age = 8 age_lower = 6 age_upper = 11 - milestone_group_id = 1 milestone_group = session.exec( select(MilestoneGroup).where(MilestoneGroup.id == 1) @@ -142,26 +101,12 @@ def test_calculate_milestone_group_age_scores(session): if a.milestone_id in milestones ] score = calculate_milestone_group_age_scores( - session, milestone_group_id, age, age_lower, age_upper + session, milestone_group, age, age_lower, age_upper ) assert score.age_months == 8 assert score.group_id == 1 assert score.avg_score == 2.5 assert score.sigma_score == np.std( answers, - ddof=1, + correction=1, ) - - answersession = session.exec(select(MilestoneAnswerSession)).first() - answers = [ - a - for a in session.exec(select(MilestoneAnswer)).all() - if a.milestone_id in milestones and a.answer_session_id == answersession.id - ] - score = calculate_milestone_group_age_scores( - session, milestone_group_id, age, age_lower, age_upper, answers=answers - ) - assert score.age_months == 8 - assert score.group_id == 1 - assert score.avg_score == 2.0 - assert score.sigma_score == 0.0 From 17bc4df3cb63a45faa1f1ab238d6f8dc9e9b1eab Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Tue, 19 Nov 2024 13:09:18 +0100 Subject: [PATCH 11/49] add docstrings, rename sigma variable --- .../src/mondey_backend/models/milestones.py | 4 +- .../src/mondey_backend/routers/scores.py | 25 ++++-- .../src/mondey_backend/routers/utils.py | 85 ++++++++++++++----- mondey_backend/tests/utils/test_scores.py | 8 +- mondey_backend/tests/utils/test_utils.py | 38 +++------ 5 files changed, 101 insertions(+), 59 deletions(-) diff --git a/mondey_backend/src/mondey_backend/models/milestones.py b/mondey_backend/src/mondey_backend/models/milestones.py index 7fbb3c75..7c6e3a39 100644 --- a/mondey_backend/src/mondey_backend/models/milestones.py +++ b/mondey_backend/src/mondey_backend/models/milestones.py @@ -177,7 +177,7 @@ class MilestoneAgeScore(BaseModel): milestone_id: int age_months: int avg_score: float - sigma_score: float + stddev_score: float expected_score: float @@ -190,4 +190,4 @@ class MilestoneGroupAgeScore(BaseModel): age_months: int group_id: int | None avg_score: float - sigma_score: float + stddev_score: float diff --git a/mondey_backend/src/mondey_backend/routers/scores.py b/mondey_backend/src/mondey_backend/routers/scores.py index 8057b18d..eec1de9c 100644 --- a/mondey_backend/src/mondey_backend/routers/scores.py +++ b/mondey_backend/src/mondey_backend/routers/scores.py @@ -15,6 +15,13 @@ class TrafficLight(Enum): + """ + Enum for the trafficlight feedback. + Includes -1 for red, 0 for yellow, and 1 for green. + Invalid is -2 and is included for edge cases. + + """ + invalid = -2 red = -1 yellow = 0 @@ -36,15 +43,15 @@ def compute_feedback_simple( Returns ------- int - -1 if score <= avg - 2 * sigma (trafficlight: red) - 0 if avg - 2 * sigma < score <= avg - sigma (trafficlight: yellow) - 1 if score > avg - sigma (trafficlight: green) + -1 if score <= avg - 2 * stddev (trafficlight: red) + 0 if avg - 2 * stddev < score <= avg - stddev (trafficlight: yellow) + 1 if score > avg - stddev (trafficlight: green) """ def leq(val: float, lim: float) -> bool: return val < lim or np.isclose(val, lim) - if stat.sigma_score < 1e-2: + if stat.stddev_score < 1e-2: # README: This happens when all the scores are the same, so any # deviation towards lower values can be interpreted as # underperformance. @@ -54,8 +61,8 @@ def leq(val: float, lim: float) -> bool: lim_lower = stat.avg_score - 2 lim_upper = stat.avg_score - 1 else: - lim_lower = stat.avg_score - 2 * stat.sigma_score - lim_upper = stat.avg_score - stat.sigma_score + lim_lower = stat.avg_score - 2 * stat.stddev_score + lim_upper = stat.avg_score - stat.stddev_score if leq(score, lim_lower): return TrafficLight.red.value @@ -101,9 +108,9 @@ def compute_feedback_for_milestonegroup( ------- int | tuple[int, dict[int, int]] the trafficlight feedback for the milestone group. - -1 if child score <= group_avg - 2 * group_sigma (trafficlight: red) - 0 if group_avg - 2 * group_sigma < score <= group_avg - group_sigma (trafficlight: yellow) - 1 if score > group_avg - group_sigma (trafficlight: green) + -1 if child score <= group_avg - 2 * group_stddev (trafficlight: red) + 0 if group_avg - 2 * group_stddev < score <= group_avg - group_stddev (trafficlight: yellow) + 1 if score > group_avg - group_stddev (trafficlight: green) If with_detailed is True, a tuple is returned with the first element being the total feedback and the second element being a dictionary with the feedback for each milestone in the milestonegroup. """ diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index e3f9cf20..7cd04873 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -186,12 +186,29 @@ def _get_expected_age_from_scores(scores: np.ndarray) -> int: def _get_average_scores_by_age( answers: Sequence[MilestoneAnswer], child_ages: dict[int, int] ) -> tuple[np.ndarray, np.ndarray]: + """ + Compute average and standard deviation of scores for each age class that + is observed in the answers. + + Parameters + ---------- + answers : Sequence[MilestoneAnswer] + list of answer objects + child_ages : dict[int, int] + dictionary mapping child answer session ids to ages in months + + Returns + ------- + tuple[np.ndarray, np.ndarray] + tuple of the form (a, s) where a, are numpy arrays containing the average and standard deviation of the scores for each age in months. + """ + max_age_months = 72 avg_scores = np.zeros(max_age_months + 1) - sigma_scores = np.zeros(max_age_months + 1) + stddev_scores = np.zeros(max_age_months + 1) counts = np.zeros_like(avg_scores) if child_ages == {}: - return avg_scores, sigma_scores + return avg_scores, stddev_scores # compute average for answer in answers: @@ -206,35 +223,42 @@ def _get_average_scores_by_age( # compute standard deviation for answer in answers: age = child_ages[answer.answer_session_id] # type: ignore - sigma_scores[age] += (answer.answer + 1 - avg_scores[age]) ** 2 + stddev_scores[age] += (answer.answer + 1 - avg_scores[age]) ** 2 with np.errstate(invalid="ignore"): - sigma_scores = np.sqrt(sigma_scores / np.max(counts - 1, 0)) + stddev_scores = np.sqrt(stddev_scores / np.max(counts - 1, 0)) # replace NaNs (due to zero counts) with zeros avg = np.nan_to_num(avg_scores) - sigma = np.nan_to_num(sigma_scores) + stddev = np.nan_to_num(stddev_scores) - return avg, sigma + return avg, stddev def calculate_milestone_age_scores( session: SessionDep, milestone_id: int, - answers: Sequence[MilestoneAnswer] | None = None, ) -> MilestoneAgeScores: + """ + Calculate the average and standard deviation of the scores for a milestone + at each age in months. This uses all available answers for a milestone. + Parameters + ---------- + session : SessionDep + database session + milestone_id : int + id of the milestone to compute the statistics for + Returns + ------- + MilestoneAgeScores + MilestoneAgeScores object containing the average and standard deviation of the scores for a single milestone for each age in range of ages the mondey system looks at. + """ child_ages = _get_answer_session_child_ages_in_months(session) - if answers is None: - answers = session.exec( - select(MilestoneAnswer).where( - col(MilestoneAnswer.milestone_id) == milestone_id - ) - ).all() - else: - answers = [answer for answer in answers if answer.milestone_id == milestone_id] - - avg, sigma = _get_average_scores_by_age(answers, child_ages) + answers = session.exec( + select(MilestoneAnswer).where(col(MilestoneAnswer.milestone_id) == milestone_id) + ).all() + avg, stddev = _get_average_scores_by_age(answers, child_ages) expected_age = _get_expected_age_from_scores(avg) return MilestoneAgeScores( @@ -244,7 +268,7 @@ def calculate_milestone_age_scores( milestone_id=milestone_id, age_months=age, avg_score=avg[age], - sigma_score=sigma[age], + stddev_score=stddev[age], expected_score=(4 if age >= expected_age else 1), ) for age in range(0, len(avg)) @@ -259,6 +283,27 @@ def calculate_milestone_group_age_scores( age_lower: int = 6, age_upper: int = 6, ) -> MilestoneGroupAgeScore: + """ + Calculate the average and standard deviation of the scores for a milestone group at a given age range. The age range is defined by the age parameter and the age_lower and age_upper parameters: [age - age_lower, age + age_upper]. This uses all available answers for a milestone group. + + Parameters + ---------- + session : SessionDep + database session + milestonegroup : MilestoneGroup + Milestonegroup to calculate the scores for + age : int + age in months to use as the anchor for the age range + age_lower : int, optional + value to compute the lower bound for the age range, by default 6 + age_upper : int, optional + value to compute the upper bound for the age range, by default 6 + + Returns + ------- + MilestoneGroupAgeScore + Struct containing the average and standard deviation of the scores for a single milestone group + """ answers = [] for milestone in milestonegroup.milestones: m_answers = [ @@ -274,12 +319,12 @@ def calculate_milestone_group_age_scores( answers = np.array(answers) + 1 # convert 0-3 answer index to 1-4 score avg_group = np.nan_to_num(np.mean(answers)) - sigma_group = np.nan_to_num(np.std(answers, correction=1)) + stddev_group = np.nan_to_num(np.std(answers, correction=1)) mg_score = MilestoneGroupAgeScore( age_months=age, group_id=milestonegroup.id, avg_score=avg_group, - sigma_score=sigma_group, + stddev_score=stddev_group, ) return mg_score diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_scores.py index 7855c13d..3b6b005a 100644 --- a/mondey_backend/tests/utils/test_scores.py +++ b/mondey_backend/tests/utils/test_scores.py @@ -8,7 +8,11 @@ def test_compute_feedback_simple(): dummy_scores = MilestoneAgeScore( - milestone_id=1, age_months=8, avg_score=2.0, sigma_score=0.8, expected_score=1.0 + milestone_id=1, + age_months=8, + avg_score=2.0, + stddev_score=0.8, + expected_score=1.0, ) score = 0 assert compute_feedback_simple(dummy_scores, score) == -1 @@ -22,7 +26,7 @@ def test_compute_feedback_simple(): def test_compute_feedback_simple_bad_data(): dummy_scores = MilestoneAgeScore( - milestone_id=1, age_months=8, avg_score=2.0, sigma_score=0, expected_score=1.0 + milestone_id=1, age_months=8, avg_score=2.0, stddev_score=0, expected_score=1.0 ) score = 0 assert compute_feedback_simple(dummy_scores, score) == -1 diff --git a/mondey_backend/tests/utils/test_utils.py b/mondey_backend/tests/utils/test_utils.py index 926d02e0..0d901a3c 100644 --- a/mondey_backend/tests/utils/test_utils.py +++ b/mondey_backend/tests/utils/test_utils.py @@ -2,7 +2,6 @@ from sqlmodel import select from mondey_backend.models.milestones import MilestoneAnswer -from mondey_backend.models.milestones import MilestoneAnswerSession from mondey_backend.models.milestones import MilestoneGroup from mondey_backend.routers.utils import _get_answer_session_child_ages_in_months from mondey_backend.routers.utils import _get_average_scores_by_age @@ -22,23 +21,23 @@ def test_get_average_scores_by_age(session): answers = session.exec(select(MilestoneAnswer)).all() child_ages = {1: 5, 2: 3, 3: 8} - avg, sigma = _get_average_scores_by_age(answers, child_ages) + avg, stddev = _get_average_scores_by_age(answers, child_ages) assert avg[5] == 1.5 assert avg[3] == 3.5 assert avg[8] == 3.0 - assert sigma[5] == np.std( + assert stddev[5] == np.std( [answer.answer + 1 for answer in answers if answer.answer_session_id == 1], ddof=1, ) - assert sigma[3] == np.std( + assert stddev[3] == np.std( [answer.answer + 1 for answer in answers if answer.answer_session_id == 2], ddof=1, ) - assert sigma[8] == np.nan_to_num( + assert stddev[8] == np.nan_to_num( np.std( [answer.answer + 1 for answer in answers if answer.answer_session_id == 3], ddof=1, @@ -46,15 +45,15 @@ def test_get_average_scores_by_age(session): ) child_ages = {} # no answer sessions ==> empty child ages - avg, sigma = _get_average_scores_by_age(answers, child_ages) + avg, stddev = _get_average_scores_by_age(answers, child_ages) assert np.all(avg == 0) - assert np.all(sigma == 0) + assert np.all(stddev == 0) child_ages = {1: 5, 2: 3, 3: 8} answers = [] # no answers ==> empty answers - avg, sigma = _get_average_scores_by_age(answers, child_ages) + avg, stddev = _get_average_scores_by_age(answers, child_ages) assert np.all(avg == 0) - assert np.all(sigma == 0) + assert np.all(stddev == 0) def test_calculate_milestone_age_scores(session): @@ -63,27 +62,14 @@ def test_calculate_milestone_age_scores(session): # only some are filled assert mscore.scores[8].avg_score == 2.0 - assert mscore.scores[8].sigma_score == 0.0 + assert mscore.scores[8].stddev_score == 0.0 assert mscore.scores[9].avg_score == 4.0 - assert mscore.scores[9].sigma_score == 0.0 + assert mscore.scores[9].stddev_score == 0.0 for score in mscore.scores: if score.age_months not in [8, 9]: assert score.avg_score == 0.0 - assert score.sigma_score == 0.0 - - # get milestoneanswersession #1 - answersession = session.exec(select(MilestoneAnswerSession)).first() - mscore = calculate_milestone_age_scores( - session, 1, answers=answersession.answers.values() - ) - assert mscore.scores[8].avg_score == 2.0 - assert mscore.scores[8].sigma_score == 0 - - for score in mscore.scores: - if score.age_months not in [8]: - assert score.avg_score == 0.0 - assert score.sigma_score == 0.0 + assert score.stddev_score == 0.0 def test_calculate_milestone_group_age_scores(session): @@ -106,7 +92,7 @@ def test_calculate_milestone_group_age_scores(session): assert score.age_months == 8 assert score.group_id == 1 assert score.avg_score == 2.5 - assert score.sigma_score == np.std( + assert score.stddev_score == np.std( answers, correction=1, ) From 372a80a6830c844889a4ed4130a4ec3df9bdce33 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Tue, 19 Nov 2024 14:31:49 +0100 Subject: [PATCH 12/49] finish route implementation --- .../src/mondey_backend/routers/scores.py | 6 +-- .../src/mondey_backend/routers/users.py | 50 ++++++++++++++++--- mondey_backend/tests/routers/test_users.py | 25 ++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/mondey_backend/src/mondey_backend/routers/scores.py b/mondey_backend/src/mondey_backend/routers/scores.py index eec1de9c..4377176f 100644 --- a/mondey_backend/src/mondey_backend/routers/scores.py +++ b/mondey_backend/src/mondey_backend/routers/scores.py @@ -81,7 +81,7 @@ def compute_feedback_for_milestonegroup( age_limit_high: int = 6, with_detailed: bool = False, mg_score=None, -) -> tuple[int, dict[int, int]] | tuple[int, None]: +) -> tuple[int, dict[int, int]] | int: """ Compute trafficlight feedback for milestonegroup. @@ -126,7 +126,7 @@ def compute_feedback_for_milestonegroup( } if answers == {}: - return TrafficLight.invalid.value, None + return TrafficLight.invalid.value # compute value for child mean_score_child = np.nan_to_num(np.mean([a.answer for a in answers.values()])) @@ -162,4 +162,4 @@ def compute_feedback_for_milestonegroup( return total_feedback, detailed_feedback else: - return total_feedback, None + return total_feedback diff --git a/mondey_backend/src/mondey_backend/routers/users.py b/mondey_backend/src/mondey_backend/routers/users.py index 6fa709d4..dfce8410 100644 --- a/mondey_backend/src/mondey_backend/routers/users.py +++ b/mondey_backend/src/mondey_backend/routers/users.py @@ -23,9 +23,13 @@ from ..models.users import UserRead from ..models.users import UserUpdate from ..users import fastapi_users +from .scores import TrafficLight +from .scores import compute_feedback_for_milestonegroup +from .utils import _session_has_expired from .utils import add from .utils import child_image_path from .utils import get +from .utils import get_child_age_in_months from .utils import get_db_child from .utils import get_or_create_current_milestone_answer_session from .utils import write_file @@ -236,16 +240,50 @@ def update_current_child_answers( return {"ok": True} @router.get( - "/feedback/{child_id}/{milestonegroup_id}", - response_model=tuple[int, dict[int, int] | None], + "/feedback/child={child_id}/milestonegroup={milestonegroup_id}", + response_model=dict[str, tuple[int, dict[int, int]] | int], ) - def get_feedback_for_milestoneGroup( + def get_feedback_for_milestonegroup( session: SessionDep, current_active_user: CurrentActiveUserDep, child_id: int, milestonegroup_id: int, - ) -> tuple[int, dict[int, int] | None]: - # TODO: Implement this endpoint - return 0, {0: -1} + with_detailed: bool = False, + ) -> dict[str, tuple[int, dict[int, int]] | int]: + results: dict[str, tuple[int, dict[int, int]] | int] = {} + # get all answer sessions and filter for completed ones + answersessions = [ + a + for a in session.exec( + select(MilestoneAnswerSession).where( + col(MilestoneAnswerSession.child_id) == child_id + and col(MilestoneAnswerSession.user_id) == current_active_user.id + ) + ).all() + if _session_has_expired(a) + ] + if answersessions == []: + return {"unknown": TrafficLight.invalid.value} # type: ignore + else: + for answersession in answersessions: + child = get_db_child( + session, current_active_user, answersession.child_id + ) + result = compute_feedback_for_milestonegroup( + session, + milestonegroup_id, + answersession, + get_child_age_in_months(child, answersession.created_at), + age_limit_low=6, + age_limit_high=6, + with_detailed=with_detailed, + ) + datestring = answersession.created_at.strftime("%Y-%m-%d") + if with_detailed: + total, detailed = result # type: ignore + results[datestring] = (total, detailed) + else: + results[datestring] = result # type: ignore + return results return router diff --git a/mondey_backend/tests/routers/test_users.py b/mondey_backend/tests/routers/test_users.py index 0289c0eb..0d143a81 100644 --- a/mondey_backend/tests/routers/test_users.py +++ b/mondey_backend/tests/routers/test_users.py @@ -347,3 +347,28 @@ def test_update_current_child_answers_no_prexisting( json=child_answers, ) assert response.status_code == 404 + + +def test_get_milestonegroup_feedback(user_client: TestClient): + response = user_client.get( + "/users/feedback/child=1/milestonegroup=1?with_detailed=true" + ) + assert response.status_code == 200 + assert response.json() == { + "2024-10-20": [0, {"1": 0, "2": 0}], + } + response = user_client.get( + "/users/feedback/child=1/milestonegroup=1?with_detailed=false" + ) + assert response.status_code == 200 + assert response.json() == {"2024-10-20": 0} + + +def test_get_milestonegroup_feedback_invalid_user(public_client: TestClient): + response = public_client.get("/users/feedback/child=1/milestonegroup=1") + assert response.status_code == 401 + + +def test_get_milestonegroup_feedback_invalid_milestonegroup(user_client: TestClient): + response = user_client.get("/users/feedback/child=1/milestonegroup=5") + assert response.status_code == 404 From 5923746ff1b7f1043345fc69a8d35b12d752f66e Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Tue, 19 Nov 2024 14:33:23 +0100 Subject: [PATCH 13/49] fix type annotations --- .../src/mondey_backend/routers/users.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/mondey_backend/src/mondey_backend/routers/users.py b/mondey_backend/src/mondey_backend/routers/users.py index dfce8410..79050261 100644 --- a/mondey_backend/src/mondey_backend/routers/users.py +++ b/mondey_backend/src/mondey_backend/routers/users.py @@ -269,21 +269,23 @@ def get_feedback_for_milestonegroup( child = get_db_child( session, current_active_user, answersession.child_id ) - result = compute_feedback_for_milestonegroup( - session, - milestonegroup_id, - answersession, - get_child_age_in_months(child, answersession.created_at), - age_limit_low=6, - age_limit_high=6, - with_detailed=with_detailed, + result: tuple[int, dict[int, int]] | int = ( + compute_feedback_for_milestonegroup( + session, + milestonegroup_id, + answersession, + get_child_age_in_months(child, answersession.created_at), + age_limit_low=6, + age_limit_high=6, + with_detailed=with_detailed, + ) ) datestring = answersession.created_at.strftime("%Y-%m-%d") if with_detailed: total, detailed = result # type: ignore results[datestring] = (total, detailed) else: - results[datestring] = result # type: ignore + results[datestring] = result return results return router From 52f964883a1d57b1c3c4921c9da8eab364cda415 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Tue, 19 Nov 2024 14:43:07 +0100 Subject: [PATCH 14/49] adjust tests --- .../src/mondey_backend/routers/scores.py | 6 +++++- mondey_backend/tests/utils/test_scores.py | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/mondey_backend/src/mondey_backend/routers/scores.py b/mondey_backend/src/mondey_backend/routers/scores.py index 4377176f..6fa35489 100644 --- a/mondey_backend/src/mondey_backend/routers/scores.py +++ b/mondey_backend/src/mondey_backend/routers/scores.py @@ -126,7 +126,11 @@ def compute_feedback_for_milestonegroup( } if answers == {}: - return TrafficLight.invalid.value + return ( + (TrafficLight.invalid.value, {-1: TrafficLight.invalid.value}) + if with_detailed + else TrafficLight.invalid.value + ) # compute value for child mean_score_child = np.nan_to_num(np.mean([a.answer for a in answers.values()])) diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_scores.py index 3b6b005a..6410a0b3 100644 --- a/mondey_backend/tests/utils/test_scores.py +++ b/mondey_backend/tests/utils/test_scores.py @@ -63,7 +63,7 @@ def test_compute_feedback_for_milestonegroup(session): assert total == 0 assert detailed == {1: 0, 2: 0} - total, detailed = compute_feedback_for_milestonegroup( + total = compute_feedback_for_milestonegroup( session, 1, answersession, @@ -74,12 +74,22 @@ def test_compute_feedback_for_milestonegroup(session): ) assert total == 0 - assert detailed is None def test_compute_feedback_for_milestonegroup_bad_data(session): answersession = session.exec(select(MilestoneAnswerSession)).first() answersession.answers = {} + total = compute_feedback_for_milestonegroup( + session, + 1, + answersession, + 8, + age_limit_low=6, + age_limit_high=6, + with_detailed=False, + ) + assert total == -2 + total, detailed = compute_feedback_for_milestonegroup( session, 1, @@ -89,5 +99,6 @@ def test_compute_feedback_for_milestonegroup_bad_data(session): age_limit_high=6, with_detailed=True, ) + assert total == -2 - assert detailed is None + assert detailed == {-1: -2} From eabadd18ae67940ff93c78245ae618e9334a0f5b Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Tue, 19 Nov 2024 15:40:36 +0100 Subject: [PATCH 15/49] work on frontend integration of feedback --- frontend/src/lib/client/schemas.gen.ts | 10 +++- frontend/src/lib/client/services.gen.ts | 22 +++++++- frontend/src/lib/client/types.gen.ts | 33 +++++++++++ .../lib/components/ChildrenFeedback.svelte | 55 +++++++++++++++++++ .../components/ChildrenRegistration.svelte | 11 ++++ frontend/src/lib/stores/componentStore.ts | 2 + frontend/src/locales/de.json | 5 +- mondey_backend/openapi.json | 2 +- .../src/mondey_backend/routers/users.py | 19 +++++++ .../src/mondey_backend/routers/utils.py | 2 +- mondey_backend/tests/routers/test_users.py | 4 ++ 11 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/components/ChildrenFeedback.svelte diff --git a/frontend/src/lib/client/schemas.gen.ts b/frontend/src/lib/client/schemas.gen.ts index 5b94feaf..defceeb5 100644 --- a/frontend/src/lib/client/schemas.gen.ts +++ b/frontend/src/lib/client/schemas.gen.ts @@ -455,6 +455,10 @@ export const MilestoneAdminSchema = { export const MilestoneAgeScoreSchema = { properties: { + milestone_id: { + type: 'integer', + title: 'Milestone Id' + }, age_months: { type: 'integer', title: 'Age Months' @@ -463,13 +467,17 @@ export const MilestoneAgeScoreSchema = { type: 'number', title: 'Avg Score' }, + stddev_score: { + type: 'number', + title: 'Stddev Score' + }, expected_score: { type: 'number', title: 'Expected Score' } }, type: 'object', - required: ['age_months', 'avg_score', 'expected_score'], + required: ['milestone_id', 'age_months', 'avg_score', 'stddev_score', 'expected_score'], title: 'MilestoneAgeScore' } as const; diff --git a/frontend/src/lib/client/services.gen.ts b/frontend/src/lib/client/services.gen.ts index 5c6f40d7..52f0fc91 100644 --- a/frontend/src/lib/client/services.gen.ts +++ b/frontend/src/lib/client/services.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import { createClient, createConfig, type Options, formDataBodySerializer, urlSearchParamsBodySerializer } from '@hey-api/client-fetch'; -import type { GetLanguagesError, GetLanguagesResponse, GetMilestonesError, GetMilestonesResponse, GetMilestoneData, GetMilestoneError, GetMilestoneResponse, GetMilestoneGroupsData, GetMilestoneGroupsError, GetMilestoneGroupsResponse, GetUserQuestionsError, GetUserQuestionsResponse, GetChildQuestionsError, GetChildQuestionsResponse, CreateLanguageData, CreateLanguageError, CreateLanguageResponse, DeleteLanguageData, DeleteLanguageError, DeleteLanguageResponse, UpdateI18NData, UpdateI18NError, UpdateI18NResponse, GetMilestoneGroupsAdminError, GetMilestoneGroupsAdminResponse, CreateMilestoneGroupAdminError, CreateMilestoneGroupAdminResponse, UpdateMilestoneGroupAdminData, UpdateMilestoneGroupAdminError, UpdateMilestoneGroupAdminResponse, DeleteMilestoneGroupAdminData, DeleteMilestoneGroupAdminError, DeleteMilestoneGroupAdminResponse, OrderMilestoneGroupsAdminData, OrderMilestoneGroupsAdminError, OrderMilestoneGroupsAdminResponse, UploadMilestoneGroupImageData, UploadMilestoneGroupImageError, UploadMilestoneGroupImageResponse, CreateMilestoneData, CreateMilestoneError, CreateMilestoneResponse, UpdateMilestoneData, UpdateMilestoneError, UpdateMilestoneResponse, DeleteMilestoneData, DeleteMilestoneError, DeleteMilestoneResponse, OrderMilestonesAdminData, OrderMilestonesAdminError, OrderMilestonesAdminResponse, UploadMilestoneImageData, UploadMilestoneImageError, UploadMilestoneImageResponse, DeleteMilestoneImageData, DeleteMilestoneImageError, DeleteMilestoneImageResponse, GetMilestoneAgeScoresData, GetMilestoneAgeScoresError, GetMilestoneAgeScoresResponse, GetUserQuestionsAdminError, GetUserQuestionsAdminResponse, UpdateUserQuestionData, UpdateUserQuestionError, UpdateUserQuestionResponse, CreateUserQuestionError, CreateUserQuestionResponse, DeleteUserQuestionData, DeleteUserQuestionError, DeleteUserQuestionResponse, OrderUserQuestionsAdminData, OrderUserQuestionsAdminError, OrderUserQuestionsAdminResponse, GetChildQuestionsAdminError, GetChildQuestionsAdminResponse, UpdateChildQuestionData, UpdateChildQuestionError, UpdateChildQuestionResponse, CreateChildQuestionError, CreateChildQuestionResponse, DeleteChildQuestionData, DeleteChildQuestionError, DeleteChildQuestionResponse, OrderChildQuestionsAdminData, OrderChildQuestionsAdminError, OrderChildQuestionsAdminResponse, GetUsersError, GetUsersResponse, UsersCurrentUserError, UsersCurrentUserResponse, UsersPatchCurrentUserData, UsersPatchCurrentUserError, UsersPatchCurrentUserResponse, UsersUserData, UsersUserError, UsersUserResponse, UsersPatchUserData, UsersPatchUserError, UsersPatchUserResponse, UsersDeleteUserData, UsersDeleteUserError, UsersDeleteUserResponse, GetChildrenError, GetChildrenResponse, UpdateChildData, UpdateChildError, UpdateChildResponse, CreateChildData, CreateChildError, CreateChildResponse, GetChildData, GetChildError, GetChildResponse, DeleteChildData, DeleteChildError, DeleteChildResponse, GetChildImageData, GetChildImageError, GetChildImageResponse, UploadChildImageData, UploadChildImageError, UploadChildImageResponse, DeleteChildImageData, DeleteChildImageError, DeleteChildImageResponse, GetCurrentMilestoneAnswerSessionData, GetCurrentMilestoneAnswerSessionError, GetCurrentMilestoneAnswerSessionResponse, UpdateMilestoneAnswerData, UpdateMilestoneAnswerError, UpdateMilestoneAnswerResponse, GetCurrentUserAnswersError, GetCurrentUserAnswersResponse, UpdateCurrentUserAnswersData, UpdateCurrentUserAnswersError, UpdateCurrentUserAnswersResponse, GetCurrentChildAnswersData, GetCurrentChildAnswersError, GetCurrentChildAnswersResponse, UpdateCurrentChildAnswersData, UpdateCurrentChildAnswersError, UpdateCurrentChildAnswersResponse, AuthCookieLoginData, AuthCookieLoginError, AuthCookieLoginResponse, AuthCookieLogoutError, AuthCookieLogoutResponse, RegisterRegisterData, RegisterRegisterError, RegisterRegisterResponse, ResetForgotPasswordData, ResetForgotPasswordError, ResetForgotPasswordResponse, ResetResetPasswordData, ResetResetPasswordError, ResetResetPasswordResponse, VerifyRequestTokenData, VerifyRequestTokenError, VerifyRequestTokenResponse, VerifyVerifyData, VerifyVerifyError, VerifyVerifyResponse, AuthError, AuthResponse } from './types.gen'; +import type { GetLanguagesError, GetLanguagesResponse, GetMilestonesError, GetMilestonesResponse, GetMilestoneData, GetMilestoneError, GetMilestoneResponse, GetMilestoneGroupsData, GetMilestoneGroupsError, GetMilestoneGroupsResponse, GetUserQuestionsError, GetUserQuestionsResponse, GetChildQuestionsError, GetChildQuestionsResponse, CreateLanguageData, CreateLanguageError, CreateLanguageResponse, DeleteLanguageData, DeleteLanguageError, DeleteLanguageResponse, UpdateI18NData, UpdateI18NError, UpdateI18NResponse, GetMilestoneGroupsAdminError, GetMilestoneGroupsAdminResponse, CreateMilestoneGroupAdminError, CreateMilestoneGroupAdminResponse, UpdateMilestoneGroupAdminData, UpdateMilestoneGroupAdminError, UpdateMilestoneGroupAdminResponse, DeleteMilestoneGroupAdminData, DeleteMilestoneGroupAdminError, DeleteMilestoneGroupAdminResponse, OrderMilestoneGroupsAdminData, OrderMilestoneGroupsAdminError, OrderMilestoneGroupsAdminResponse, UploadMilestoneGroupImageData, UploadMilestoneGroupImageError, UploadMilestoneGroupImageResponse, CreateMilestoneData, CreateMilestoneError, CreateMilestoneResponse, UpdateMilestoneData, UpdateMilestoneError, UpdateMilestoneResponse, DeleteMilestoneData, DeleteMilestoneError, DeleteMilestoneResponse, OrderMilestonesAdminData, OrderMilestonesAdminError, OrderMilestonesAdminResponse, UploadMilestoneImageData, UploadMilestoneImageError, UploadMilestoneImageResponse, DeleteMilestoneImageData, DeleteMilestoneImageError, DeleteMilestoneImageResponse, GetMilestoneAgeScoresData, GetMilestoneAgeScoresError, GetMilestoneAgeScoresResponse, GetUserQuestionsAdminError, GetUserQuestionsAdminResponse, UpdateUserQuestionData, UpdateUserQuestionError, UpdateUserQuestionResponse, CreateUserQuestionError, CreateUserQuestionResponse, DeleteUserQuestionData, DeleteUserQuestionError, DeleteUserQuestionResponse, OrderUserQuestionsAdminData, OrderUserQuestionsAdminError, OrderUserQuestionsAdminResponse, GetChildQuestionsAdminError, GetChildQuestionsAdminResponse, UpdateChildQuestionData, UpdateChildQuestionError, UpdateChildQuestionResponse, CreateChildQuestionError, CreateChildQuestionResponse, DeleteChildQuestionData, DeleteChildQuestionError, DeleteChildQuestionResponse, OrderChildQuestionsAdminData, OrderChildQuestionsAdminError, OrderChildQuestionsAdminResponse, GetUsersError, GetUsersResponse, UsersCurrentUserError, UsersCurrentUserResponse, UsersPatchCurrentUserData, UsersPatchCurrentUserError, UsersPatchCurrentUserResponse, UsersUserData, UsersUserError, UsersUserResponse, UsersPatchUserData, UsersPatchUserError, UsersPatchUserResponse, UsersDeleteUserData, UsersDeleteUserError, UsersDeleteUserResponse, GetChildrenError, GetChildrenResponse, UpdateChildData, UpdateChildError, UpdateChildResponse, CreateChildData, CreateChildError, CreateChildResponse, GetChildData, GetChildError, GetChildResponse, DeleteChildData, DeleteChildError, DeleteChildResponse, GetChildImageData, GetChildImageError, GetChildImageResponse, UploadChildImageData, UploadChildImageError, UploadChildImageResponse, DeleteChildImageData, DeleteChildImageError, DeleteChildImageResponse, GetCurrentMilestoneAnswerSessionData, GetCurrentMilestoneAnswerSessionError, GetCurrentMilestoneAnswerSessionResponse, GetExpiredMilestoneAnswerSessionsData, GetExpiredMilestoneAnswerSessionsError, GetExpiredMilestoneAnswerSessionsResponse, UpdateMilestoneAnswerData, UpdateMilestoneAnswerError, UpdateMilestoneAnswerResponse, GetCurrentUserAnswersError, GetCurrentUserAnswersResponse, UpdateCurrentUserAnswersData, UpdateCurrentUserAnswersError, UpdateCurrentUserAnswersResponse, GetCurrentChildAnswersData, GetCurrentChildAnswersError, GetCurrentChildAnswersResponse, UpdateCurrentChildAnswersData, UpdateCurrentChildAnswersError, UpdateCurrentChildAnswersResponse, GetFeedbackForMilestonegroupData, GetFeedbackForMilestonegroupError, GetFeedbackForMilestonegroupResponse, AuthCookieLoginData, AuthCookieLoginError, AuthCookieLoginResponse, AuthCookieLogoutError, AuthCookieLogoutResponse, RegisterRegisterData, RegisterRegisterError, RegisterRegisterResponse, ResetForgotPasswordData, ResetForgotPasswordError, ResetForgotPasswordResponse, ResetResetPasswordData, ResetResetPasswordError, ResetResetPasswordResponse, VerifyRequestTokenData, VerifyRequestTokenError, VerifyRequestTokenResponse, VerifyVerifyData, VerifyVerifyError, VerifyVerifyResponse, AuthError, AuthResponse } from './types.gen'; export const client = createClient(createConfig()); @@ -490,6 +490,16 @@ export const getCurrentMilestoneAnswerSession = (options: Options) => { + return (options?.client ?? client).get({ + ...options, + url: '/users/milestone-answers-sessions/{child_id}' + }); +}; + /** * Update Milestone Answer */ @@ -540,6 +550,16 @@ export const updateCurrentChildAnswers = ( }); }; +/** + * Get Feedback For Milestonegroup + */ +export const getFeedbackForMilestonegroup = (options: Options) => { + return (options?.client ?? client).get({ + ...options, + url: '/users/feedback/child={child_id}/milestonegroup={milestonegroup_id}' + }); +}; + /** * Auth:Cookie.Login */ diff --git a/frontend/src/lib/client/types.gen.ts b/frontend/src/lib/client/types.gen.ts index c1bb898d..cde9d5b3 100644 --- a/frontend/src/lib/client/types.gen.ts +++ b/frontend/src/lib/client/types.gen.ts @@ -119,8 +119,10 @@ export type MilestoneAdmin = { }; export type MilestoneAgeScore = { + milestone_id: number; age_months: number; avg_score: number; + stddev_score: number; expected_score: number; }; @@ -658,6 +660,16 @@ export type GetCurrentMilestoneAnswerSessionResponse = (MilestoneAnswerSessionPu export type GetCurrentMilestoneAnswerSessionError = (HTTPValidationError); +export type GetExpiredMilestoneAnswerSessionsData = { + path: { + child_id: number; + }; +}; + +export type GetExpiredMilestoneAnswerSessionsResponse = (Array); + +export type GetExpiredMilestoneAnswerSessionsError = (HTTPValidationError); + export type UpdateMilestoneAnswerData = { body: MilestoneAnswerPublic; path: { @@ -706,6 +718,27 @@ export type UpdateCurrentChildAnswersResponse = (unknown); export type UpdateCurrentChildAnswersError = (HTTPValidationError); +export type GetFeedbackForMilestonegroupData = { + path: { + child_id: number; + milestonegroup_id: number; + }; + query?: { + with_detailed?: boolean; + }; +}; + +export type GetFeedbackForMilestonegroupResponse = ({ + [key: string]: ([ + number, + { + [key: string]: (number); + } +] | number); +}); + +export type GetFeedbackForMilestonegroupError = (HTTPValidationError); + export type AuthCookieLoginData = { body: Body_auth_cookie_login_auth_login_post; }; diff --git a/frontend/src/lib/components/ChildrenFeedback.svelte b/frontend/src/lib/components/ChildrenFeedback.svelte new file mode 100644 index 00000000..575ac5d1 --- /dev/null +++ b/frontend/src/lib/components/ChildrenFeedback.svelte @@ -0,0 +1,55 @@ + + + +{#await promise} + {$_("childData.loadingMessage")} +{:then _} +
+ TODO: implement childrenfeedback visuals +
+{:catch error} + +{/await} diff --git a/frontend/src/lib/components/ChildrenRegistration.svelte b/frontend/src/lib/components/ChildrenRegistration.svelte index 4a0953da..da5fc854 100644 --- a/frontend/src/lib/components/ChildrenRegistration.svelte +++ b/frontend/src/lib/components/ChildrenRegistration.svelte @@ -397,6 +397,17 @@ async function submitData(): Promise { {$_("childData.nextButtonLabel")} +
+ + {milestone.text[$locale as string].help} + + {:else} + + + + {milestone.text[$locale as string].help} + {/if} - {/if} + {/snippet} + + +{#if showAlert} + +{/if} {#await promise} -
-

{$_("childData.loadingMessage")}

-
+
+

{$_("childData.loadingMessage")}

+
{:then} -
- - {#each sessionkeys as aid} - - - {@render evaluation(feedbackPerAnswersession[aid])} - -
- - {#each Object.entries(feedbackPerAnswersession[aid]) as [mid, score]} - { - console.log("clicked on accordion item"); - const response = await getDetailedFeedbackForMilestonegroup({ - path: { - answersession_id: aid, - milestonegroup_id: Number(mid) - } - }); - if (response.error) { - showAlert = true; - alertMessage = response.error.detail; - return; - } - console.log("detailed feedback: ", response.data); - detailed = response.data; - - }}> - {milestongeGroups[aid][Number(mid)].text[$locale as string].title} - {@render evaluation(score as Record, false)} - {#each Object.entries(detailed) as [ms_id, ms_score]} -

{milestongeGroups[aid][Number(mid)].milestones[ms_id].text[$locale as string].title}

- {#if evaluate(ms_score) === 1} - - {:else if evaluate(ms_score) === 0} - - {:else} - - {/if} - {/each} -
- {/each} -
-
- {/each} -
-
+
+ + {#each sessionkeys as aid} + + + + + {@render evaluation(feedbackPerAnswersession[aid])} + + +
+ + + {#each Object.entries(feedbackPerAnswersession[aid]) as [mid, score]} + {#await getDetailed(aid, mid)} +

{$_("childData.loadingMessage")}

+ {:then detailed} + + +

+ {$_("milestone.milestoneGroup") } + {milestoneGroups[aid][Number(mid)].text[$locale as string].title}

+
+ {@render evaluation(score as number, true)} + +
+ +
+ {#each Object.entries(detailed) as [ms_id, ms_score]} + {@render detailedEvaluation( + milestoneGroups[aid][Number(mid)].milestones.find((element: any) => element.id === Number(ms_id)), + ms_score + )} + {/each} +
+
+ {:catch error} + + {/await} + {/each} +
+
+ {/each} +
+
{:catch error} - + {/await} diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 49f7253e..d2dfc56f 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -16,7 +16,10 @@ "autonext": "Automatisch weiter", "groupOverviewLabel": "Ɯbersicht Meilensteingruppen", "alertMessageRetrieving": "Fehler beim Abrufen der Meilensteine", - "alertMessageError": "Ein Fehler ist aufgetreten" + "alertMessageError": "Ein Fehler ist aufgetreten", + "feedbackTitle": "Feedback zur Entwicklung", + "milestone": "Meilenstein", + "milestoneGroup": "Meilensteingruppe" }, "search": { "allLabel": "Alle", diff --git a/mondey_backend/openapi.json b/mondey_backend/openapi.json index 1c49b878..ae05e785 100644 --- a/mondey_backend/openapi.json +++ b/mondey_backend/openapi.json @@ -1 +1 @@ -{"openapi": "3.1.0", "info": {"title": "MONDEY API", "version": "0.1.0"}, "paths": {"/languages/": {"get": {"tags": ["milestones"], "summary": "Get Languages", "operationId": "get_languages", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"type": "string"}, "type": "array", "title": "Response Get Languages Languages Get"}}}}}}}, "/milestones/": {"get": {"tags": ["milestones"], "summary": "Get Milestones", "operationId": "get_milestones", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/MilestonePublic"}, "type": "array", "title": "Response Get Milestones Milestones Get"}}}}}}}, "/milestones/{milestone_id}": {"get": {"tags": ["milestones"], "summary": "Get Milestone", "operationId": "get_milestone", "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestonePublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/milestone-groups/{child_id}": {"get": {"tags": ["milestones"], "summary": "Get Milestone Groups", "operationId": "get_milestone_groups", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/MilestoneGroupPublic"}, "title": "Response Get Milestone Groups Milestone Groups Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/user-questions/": {"get": {"tags": ["questions"], "summary": "Get User Questions", "operationId": "get_user_questions", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserQuestionPublic"}, "type": "array", "title": "Response Get User Questions User Questions Get"}}}}}}}, "/child-questions/": {"get": {"tags": ["questions"], "summary": "Get Child Questions", "operationId": "get_child_questions", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildQuestionPublic"}, "type": "array", "title": "Response Get Child Questions Child Questions Get"}}}}}}}, "/admin/languages/": {"post": {"tags": ["admin"], "summary": "Create Language", "operationId": "create_language", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Language"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Language"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/languages/{language_id}": {"delete": {"tags": ["admin"], "summary": "Delete Language", "operationId": "delete_language", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "language_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Language Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/i18n/{language_id}": {"put": {"tags": ["admin"], "summary": "Update I18N", "operationId": "update_i18n", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "language_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Language Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "object", "additionalProperties": {"type": "string"}}, "title": "I18Dict"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-groups/": {"get": {"tags": ["admin"], "summary": "Get Milestone Groups Admin", "operationId": "get_milestone_groups_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}, "type": "array", "title": "Response Get Milestone Groups Admin Admin Milestone Groups Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create Milestone Group Admin", "operationId": "create_milestone_group_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-groups": {"put": {"tags": ["admin"], "summary": "Update Milestone Group Admin", "operationId": "update_milestone_group_admin", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-groups/{milestone_group_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone Group Admin", "operationId": "delete_milestone_group_admin", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-groups/order/": {"post": {"tags": ["admin"], "summary": "Order Milestone Groups Admin", "operationId": "order_milestone_groups_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-group-images/{milestone_group_id}": {"put": {"tags": ["admin"], "summary": "Upload Milestone Group Image", "operationId": "upload_milestone_group_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/{milestone_group_id}": {"post": {"tags": ["admin"], "summary": "Create Milestone", "operationId": "create_milestone", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/": {"put": {"tags": ["admin"], "summary": "Update Milestone", "operationId": "update_milestone", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestones/{milestone_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone", "operationId": "delete_milestone", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/order/": {"post": {"tags": ["admin"], "summary": "Order Milestones Admin", "operationId": "order_milestones_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-images/{milestone_id}": {"post": {"tags": ["admin"], "summary": "Upload Milestone Image", "operationId": "upload_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_milestone_image_admin_milestone_images__milestone_id__post"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneImage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-images/{milestone_image_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone Image", "operationId": "delete_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_image_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Image Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-age-scores/{milestone_id}": {"get": {"tags": ["admin"], "summary": "Get Milestone Age Scores", "operationId": "get_milestone_age_scores", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAgeScores"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/user-questions/": {"get": {"tags": ["admin"], "summary": "Get User Questions Admin", "operationId": "get_user_questions_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserQuestionAdmin"}, "type": "array", "title": "Response Get User Questions Admin Admin User Questions Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["admin"], "summary": "Update User Question", "operationId": "update_user_question", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create User Question", "operationId": "create_user_question", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/user-questions/{user_question_id}": {"delete": {"tags": ["admin"], "summary": "Delete User Question", "operationId": "delete_user_question", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "user_question_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "User Question Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/user-questions/order/": {"post": {"tags": ["admin"], "summary": "Order User Questions Admin", "operationId": "order_user_questions_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/child-questions/": {"get": {"tags": ["admin"], "summary": "Get Child Questions Admin", "operationId": "get_child_questions_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildQuestionAdmin"}, "type": "array", "title": "Response Get Child Questions Admin Admin Child Questions Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["admin"], "summary": "Update Child Question", "operationId": "update_child_question", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create Child Question", "operationId": "create_child_question", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/child-questions/{child_question_id}": {"delete": {"tags": ["admin"], "summary": "Delete Child Question", "operationId": "delete_child_question", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_question_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Question Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/child-questions/order/": {"post": {"tags": ["admin"], "summary": "Order Child Questions Admin", "operationId": "order_child_questions_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/users/": {"get": {"tags": ["admin"], "summary": "Get Users", "operationId": "get_users", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserRead"}, "type": "array", "title": "Response Get Users Admin Users Get"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/me": {"get": {"tags": ["users"], "summary": "Users:Current User", "operationId": "users:current_user", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}}, "security": [{"APIKeyCookie": []}]}, "patch": {"tags": ["users"], "summary": "Users:Patch Current User", "operationId": "users:patch_current_user", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"UPDATE_USER_EMAIL_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "UPDATE_USER_EMAIL_ALREADY_EXISTS"}}, "UPDATE_USER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "UPDATE_USER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/{id}": {"get": {"tags": ["users"], "summary": "Users:User", "operationId": "users:user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["users"], "summary": "Users:Patch User", "operationId": "users:patch_user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "400": {"content": {"application/json": {"examples": {"UPDATE_USER_EMAIL_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "UPDATE_USER_EMAIL_ALREADY_EXISTS"}}, "UPDATE_USER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "UPDATE_USER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}, "schema": {"$ref": "#/components/schemas/ErrorModel"}}}, "description": "Bad Request"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Users:Delete User", "operationId": "users:delete_user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "responses": {"204": {"description": "Successful Response"}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/children/": {"get": {"tags": ["users"], "summary": "Get Children", "operationId": "get_children", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildPublic"}, "type": "array", "title": "Response Get Children Users Children Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["users"], "summary": "Update Child", "operationId": "update_child", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["users"], "summary": "Create Child", "operationId": "create_child", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildCreate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/children/{child_id}": {"get": {"tags": ["users"], "summary": "Get Child", "operationId": "get_child", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete Child", "operationId": "delete_child", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/children-images/{child_id}": {"get": {"tags": ["users"], "summary": "Get Child Image", "operationId": "get_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["users"], "summary": "Upload Child Image", "operationId": "upload_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_child_image_users_children_images__child_id__put"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete Child Image", "operationId": "delete_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers/{child_id}": {"get": {"tags": ["users"], "summary": "Get Current Milestone Answer Session", "operationId": "get_current_milestone_answer_session", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerSessionPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers/{milestone_answer_session_id}": {"put": {"tags": ["users"], "summary": "Update Milestone Answer", "operationId": "update_milestone_answer", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_answer_session_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Answer Session Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/user-answers/": {"get": {"tags": ["users"], "summary": "Get Current User Answers", "operationId": "get_current_user_answers", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "Response Get Current User Answers Users User Answers Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["users"], "summary": "Update Current User Answers", "operationId": "update_current_user_answers", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "New Answers"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "Response Update Current User Answers Users User Answers Put"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/children-answers/{child_id}": {"get": {"tags": ["users"], "summary": "Get Current Child Answers", "operationId": "get_current_child_answers", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/ChildAnswerPublic"}, "title": "Response Get Current Child Answers Users Children Answers Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["users"], "summary": "Update Current Child Answers", "operationId": "update_current_child_answers", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/ChildAnswerPublic"}, "title": "New Answers"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers-sessions/{child_id}": {"get": {"tags": ["users"], "summary": "Get Expired Milestone Answer Sessions", "operationId": "get_expired_milestone_answer_sessions", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/MilestoneAnswerSessionPublic"}, "title": "Response Get Expired Milestone Answer Sessions Users Milestone Answers Sessions Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}": {"get": {"tags": ["users"], "summary": "Get Milestonegroups For Session", "operationId": "get_milestonegroups_for_session", "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupPublic"}, "title": "Response Get Milestonegroups For Session Users Feedback Answersession Answersession Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}/milestonegroup={milestonegroup_id}/details": {"get": {"tags": ["users"], "summary": "Get Detailed Feedback For Milestonegroup", "operationId": "get_detailed_feedback_for_milestonegroup", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}, {"name": "milestonegroup_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestonegroup Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "integer"}, "title": "Response Get Detailed Feedback For Milestonegroup Users Feedback Answersession Answersession Id Milestonegroup Milestonegroup Id Details Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}/summary": {"get": {"tags": ["users"], "summary": "Get Summary Feedback For Answersession", "operationId": "get_summary_feedback_for_answersession", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "integer"}, "title": "Response Get Summary Feedback For Answersession Users Feedback Answersession Answersession Id Summary Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}/detailed": {"get": {"tags": ["users"], "summary": "Get Detailed Feedback For Answersession", "operationId": "get_detailed_feedback_for_answersession", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "object", "additionalProperties": {"type": "integer"}}, "title": "Response Get Detailed Feedback For Answersession Users Feedback Answersession Answersession Id Detailed Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/login": {"post": {"tags": ["auth"], "summary": "Auth:Cookie.Login", "operationId": "auth:cookie.login", "requestBody": {"content": {"application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/Body_auth_cookie_login_auth_login_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"LOGIN_BAD_CREDENTIALS": {"summary": "Bad credentials or the user is inactive.", "value": {"detail": "LOGIN_BAD_CREDENTIALS"}}, "LOGIN_USER_NOT_VERIFIED": {"summary": "The user is not verified.", "value": {"detail": "LOGIN_USER_NOT_VERIFIED"}}}}}}, "204": {"description": "No Content"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/logout": {"post": {"tags": ["auth"], "summary": "Auth:Cookie.Logout", "operationId": "auth:cookie.logout", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "401": {"description": "Missing token or inactive user."}, "204": {"description": "No Content"}}, "security": [{"APIKeyCookie": []}]}}, "/auth/register": {"post": {"tags": ["auth"], "summary": "Register:Register", "operationId": "register:register", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"REGISTER_USER_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "REGISTER_USER_ALREADY_EXISTS"}}, "REGISTER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "REGISTER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/forgot-password": {"post": {"tags": ["auth"], "summary": "Reset:Forgot Password", "operationId": "reset:forgot_password", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_reset_forgot_password_auth_forgot_password_post"}}}, "required": true}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/reset-password": {"post": {"tags": ["auth"], "summary": "Reset:Reset Password", "operationId": "reset:reset_password", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_reset_reset_password_auth_reset_password_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"RESET_PASSWORD_BAD_TOKEN": {"summary": "Bad or expired token.", "value": {"detail": "RESET_PASSWORD_BAD_TOKEN"}}, "RESET_PASSWORD_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "RESET_PASSWORD_INVALID_PASSWORD", "reason": "Password should be at least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/request-verify-token": {"post": {"tags": ["auth"], "summary": "Verify:Request-Token", "operationId": "verify:request-token", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_verify_request_token_auth_request_verify_token_post"}}}, "required": true}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/verify": {"post": {"tags": ["auth"], "summary": "Verify:Verify", "operationId": "verify:verify", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_verify_verify_auth_verify_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"VERIFY_USER_BAD_TOKEN": {"summary": "Bad token, not existing user ornot the e-mail currently set for the user.", "value": {"detail": "VERIFY_USER_BAD_TOKEN"}}, "VERIFY_USER_ALREADY_VERIFIED": {"summary": "The user is already verified.", "value": {"detail": "VERIFY_USER_ALREADY_VERIFIED"}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/research/auth/": {"get": {"tags": ["research"], "summary": "Auth", "operationId": "auth", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}, "security": [{"APIKeyCookie": []}]}}}, "components": {"schemas": {"Body_auth_cookie_login_auth_login_post": {"properties": {"grant_type": {"anyOf": [{"type": "string", "pattern": "password"}, {"type": "null"}], "title": "Grant Type"}, "username": {"type": "string", "title": "Username"}, "password": {"type": "string", "title": "Password"}, "scope": {"type": "string", "title": "Scope", "default": ""}, "client_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Client Id"}, "client_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Client Secret"}}, "type": "object", "required": ["username", "password"], "title": "Body_auth_cookie_login_auth_login_post"}, "Body_reset_forgot_password_auth_forgot_password_post": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}}, "type": "object", "required": ["email"], "title": "Body_reset_forgot_password_auth_forgot_password_post"}, "Body_reset_reset_password_auth_reset_password_post": {"properties": {"token": {"type": "string", "title": "Token"}, "password": {"type": "string", "title": "Password"}}, "type": "object", "required": ["token", "password"], "title": "Body_reset_reset_password_auth_reset_password_post"}, "Body_upload_child_image_users_children_images__child_id__put": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_child_image_users_children_images__child_id__put"}, "Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put"}, "Body_upload_milestone_image_admin_milestone_images__milestone_id__post": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_milestone_image_admin_milestone_images__milestone_id__post"}, "Body_verify_request_token_auth_request_verify_token_post": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}}, "type": "object", "required": ["email"], "title": "Body_verify_request_token_auth_request_verify_token_post"}, "Body_verify_verify_auth_verify_post": {"properties": {"token": {"type": "string", "title": "Token"}}, "type": "object", "required": ["token"], "title": "Body_verify_verify_auth_verify_post"}, "ChildAnswerPublic": {"properties": {"answer": {"type": "string", "title": "Answer"}, "additional_answer": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Additional Answer"}, "question_id": {"type": "integer", "title": "Question Id"}}, "type": "object", "required": ["answer", "additional_answer", "question_id"], "title": "ChildAnswerPublic"}, "ChildCreate": {"properties": {"name": {"type": "string", "title": "Name", "default": ""}, "birth_year": {"type": "integer", "title": "Birth Year"}, "birth_month": {"type": "integer", "title": "Birth Month"}}, "type": "object", "required": ["birth_year", "birth_month"], "title": "ChildCreate"}, "ChildPublic": {"properties": {"name": {"type": "string", "title": "Name", "default": ""}, "birth_year": {"type": "integer", "title": "Birth Year"}, "birth_month": {"type": "integer", "title": "Birth Month"}, "id": {"type": "integer", "title": "Id"}, "has_image": {"type": "boolean", "title": "Has Image"}}, "type": "object", "required": ["birth_year", "birth_month", "id", "has_image"], "title": "ChildPublic"}, "ChildQuestionAdmin": {"properties": {"order": {"type": "integer", "title": "Order", "default": 0}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "options": {"type": "string", "title": "Options", "default": ""}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}, "id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/ChildQuestionText"}, "type": "object", "title": "Text", "default": {}}}, "type": "object", "required": ["id"], "title": "ChildQuestionAdmin"}, "ChildQuestionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/QuestionTextPublic"}, "type": "object", "title": "Text", "default": {}}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}}, "type": "object", "required": ["id"], "title": "ChildQuestionPublic"}, "ChildQuestionText": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}, "child_question_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Child Question Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "ChildQuestionText"}, "ErrorModel": {"properties": {"detail": {"anyOf": [{"type": "string"}, {"additionalProperties": {"type": "string"}, "type": "object"}], "title": "Detail"}}, "type": "object", "required": ["detail"], "title": "ErrorModel"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "ItemOrder": {"properties": {"id": {"type": "integer", "title": "Id"}, "order": {"type": "integer", "title": "Order"}}, "type": "object", "required": ["id", "order"], "title": "ItemOrder"}, "Language": {"properties": {"id": {"type": "string", "maxLength": 2, "title": "Id"}}, "type": "object", "required": ["id"], "title": "Language"}, "MilestoneAdmin": {"properties": {"id": {"type": "integer", "title": "Id"}, "group_id": {"type": "integer", "title": "Group Id"}, "order": {"type": "integer", "title": "Order"}, "expected_age_months": {"type": "integer", "title": "Expected Age Months"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneText"}, "type": "object", "title": "Text"}, "images": {"items": {"$ref": "#/components/schemas/MilestoneImage"}, "type": "array", "title": "Images"}}, "type": "object", "required": ["id", "group_id", "order", "expected_age_months", "text", "images"], "title": "MilestoneAdmin"}, "MilestoneAgeScore": {"properties": {"milestone_id": {"type": "integer", "title": "Milestone Id"}, "age_months": {"type": "integer", "title": "Age Months"}, "avg_score": {"type": "number", "title": "Avg Score"}, "stddev_score": {"type": "number", "title": "Stddev Score"}, "expected_score": {"type": "number", "title": "Expected Score"}}, "type": "object", "required": ["milestone_id", "age_months", "avg_score", "stddev_score", "expected_score"], "title": "MilestoneAgeScore"}, "MilestoneAgeScores": {"properties": {"scores": {"items": {"$ref": "#/components/schemas/MilestoneAgeScore"}, "type": "array", "title": "Scores"}, "expected_age": {"type": "integer", "title": "Expected Age"}}, "type": "object", "required": ["scores", "expected_age"], "title": "MilestoneAgeScores"}, "MilestoneAnswerPublic": {"properties": {"milestone_id": {"type": "integer", "title": "Milestone Id"}, "answer": {"type": "integer", "title": "Answer"}}, "type": "object", "required": ["milestone_id", "answer"], "title": "MilestoneAnswerPublic"}, "MilestoneAnswerSessionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "child_id": {"type": "integer", "title": "Child Id"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "answers": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}, "type": "object", "title": "Answers"}}, "type": "object", "required": ["id", "child_id", "created_at", "answers"], "title": "MilestoneAnswerSessionPublic"}, "MilestoneGroupAdmin": {"properties": {"id": {"type": "integer", "title": "Id"}, "order": {"type": "integer", "title": "Order"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupText"}, "type": "object", "title": "Text"}, "milestones": {"items": {"$ref": "#/components/schemas/MilestoneAdmin"}, "type": "array", "title": "Milestones"}}, "type": "object", "required": ["id", "order", "text", "milestones"], "title": "MilestoneGroupAdmin"}, "MilestoneGroupPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupTextPublic"}, "type": "object", "title": "Text"}, "milestones": {"items": {"$ref": "#/components/schemas/MilestonePublic"}, "type": "array", "title": "Milestones"}}, "type": "object", "required": ["id", "text", "milestones"], "title": "MilestoneGroupPublic"}, "MilestoneGroupText": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "group_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Group Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "MilestoneGroupText"}, "MilestoneGroupTextPublic": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}}, "type": "object", "title": "MilestoneGroupTextPublic"}, "MilestoneImage": {"properties": {"id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Id"}, "milestone_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Milestone Id"}}, "type": "object", "title": "MilestoneImage"}, "MilestoneImagePublic": {"properties": {"id": {"type": "integer", "title": "Id"}}, "type": "object", "required": ["id"], "title": "MilestoneImagePublic"}, "MilestonePublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "expected_age_months": {"type": "integer", "title": "Expected Age Months"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneTextPublic"}, "type": "object", "title": "Text"}, "images": {"items": {"$ref": "#/components/schemas/MilestoneImagePublic"}, "type": "array", "title": "Images"}}, "type": "object", "required": ["id", "expected_age_months", "text", "images"], "title": "MilestonePublic"}, "MilestoneText": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "obs": {"type": "string", "title": "Obs", "default": ""}, "help": {"type": "string", "title": "Help", "default": ""}, "milestone_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Milestone Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "MilestoneText"}, "MilestoneTextPublic": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "obs": {"type": "string", "title": "Obs", "default": ""}, "help": {"type": "string", "title": "Help", "default": ""}}, "type": "object", "title": "MilestoneTextPublic"}, "QuestionTextPublic": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}}, "type": "object", "title": "QuestionTextPublic"}, "UserAnswerPublic": {"properties": {"answer": {"type": "string", "title": "Answer"}, "additional_answer": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Additional Answer"}, "question_id": {"type": "integer", "title": "Question Id"}}, "type": "object", "required": ["answer", "additional_answer", "question_id"], "title": "UserAnswerPublic"}, "UserCreate": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}, "password": {"type": "string", "title": "Password"}, "is_active": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Active", "default": true}, "is_superuser": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Superuser", "default": false}, "is_verified": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Verified", "default": false}, "is_researcher": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Researcher", "default": false}}, "type": "object", "required": ["email", "password"], "title": "UserCreate"}, "UserQuestionAdmin": {"properties": {"order": {"type": "integer", "title": "Order", "default": 0}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "options": {"type": "string", "title": "Options", "default": ""}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}, "id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/UserQuestionText"}, "type": "object", "title": "Text", "default": {}}}, "type": "object", "required": ["id"], "title": "UserQuestionAdmin"}, "UserQuestionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/QuestionTextPublic"}, "type": "object", "title": "Text", "default": {}}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}}, "type": "object", "required": ["id"], "title": "UserQuestionPublic"}, "UserQuestionText": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}, "user_question_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "User Question Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "UserQuestionText"}, "UserRead": {"properties": {"id": {"type": "integer", "title": "Id"}, "email": {"type": "string", "format": "email", "title": "Email"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "is_superuser": {"type": "boolean", "title": "Is Superuser", "default": false}, "is_verified": {"type": "boolean", "title": "Is Verified", "default": false}, "is_researcher": {"type": "boolean", "title": "Is Researcher"}}, "type": "object", "required": ["id", "email", "is_researcher"], "title": "UserRead"}, "UserUpdate": {"properties": {"password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Password"}, "email": {"anyOf": [{"type": "string", "format": "email"}, {"type": "null"}], "title": "Email"}, "is_active": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Active"}, "is_superuser": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Superuser"}, "is_verified": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Verified"}, "is_researcher": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Researcher"}}, "type": "object", "title": "UserUpdate"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}, "securitySchemes": {"APIKeyCookie": {"type": "apiKey", "in": "cookie", "name": "fastapiusersauth"}}}} \ No newline at end of file +{"openapi": "3.1.0", "info": {"title": "MONDEY API", "version": "0.1.0"}, "paths": {"/languages/": {"get": {"tags": ["milestones"], "summary": "Get Languages", "operationId": "get_languages", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"type": "string"}, "type": "array", "title": "Response Get Languages Languages Get"}}}}}}}, "/milestones/": {"get": {"tags": ["milestones"], "summary": "Get Milestones", "operationId": "get_milestones", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/MilestonePublic"}, "type": "array", "title": "Response Get Milestones Milestones Get"}}}}}}}, "/milestones/{milestone_id}": {"get": {"tags": ["milestones"], "summary": "Get Milestone", "operationId": "get_milestone", "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestonePublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/milestone-groups/{child_id}": {"get": {"tags": ["milestones"], "summary": "Get Milestone Groups", "operationId": "get_milestone_groups", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/MilestoneGroupPublic"}, "title": "Response Get Milestone Groups Milestone Groups Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/user-questions/": {"get": {"tags": ["questions"], "summary": "Get User Questions", "operationId": "get_user_questions", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserQuestionPublic"}, "type": "array", "title": "Response Get User Questions User Questions Get"}}}}}}}, "/child-questions/": {"get": {"tags": ["questions"], "summary": "Get Child Questions", "operationId": "get_child_questions", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildQuestionPublic"}, "type": "array", "title": "Response Get Child Questions Child Questions Get"}}}}}}}, "/admin/languages/": {"post": {"tags": ["admin"], "summary": "Create Language", "operationId": "create_language", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Language"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Language"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/languages/{language_id}": {"delete": {"tags": ["admin"], "summary": "Delete Language", "operationId": "delete_language", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "language_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Language Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/i18n/{language_id}": {"put": {"tags": ["admin"], "summary": "Update I18N", "operationId": "update_i18n", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "language_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Language Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "object", "additionalProperties": {"type": "string"}}, "title": "I18Dict"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-groups/": {"get": {"tags": ["admin"], "summary": "Get Milestone Groups Admin", "operationId": "get_milestone_groups_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}, "type": "array", "title": "Response Get Milestone Groups Admin Admin Milestone Groups Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create Milestone Group Admin", "operationId": "create_milestone_group_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-groups": {"put": {"tags": ["admin"], "summary": "Update Milestone Group Admin", "operationId": "update_milestone_group_admin", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-groups/{milestone_group_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone Group Admin", "operationId": "delete_milestone_group_admin", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-groups/order/": {"post": {"tags": ["admin"], "summary": "Order Milestone Groups Admin", "operationId": "order_milestone_groups_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-group-images/{milestone_group_id}": {"put": {"tags": ["admin"], "summary": "Upload Milestone Group Image", "operationId": "upload_milestone_group_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/{milestone_group_id}": {"post": {"tags": ["admin"], "summary": "Create Milestone", "operationId": "create_milestone", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/": {"put": {"tags": ["admin"], "summary": "Update Milestone", "operationId": "update_milestone", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestones/{milestone_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone", "operationId": "delete_milestone", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/order/": {"post": {"tags": ["admin"], "summary": "Order Milestones Admin", "operationId": "order_milestones_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-images/{milestone_id}": {"post": {"tags": ["admin"], "summary": "Upload Milestone Image", "operationId": "upload_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_milestone_image_admin_milestone_images__milestone_id__post"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneImage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-images/{milestone_image_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone Image", "operationId": "delete_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_image_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Image Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-age-scores/{milestone_id}": {"get": {"tags": ["admin"], "summary": "Get Milestone Age Scores", "operationId": "get_milestone_age_scores", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAgeScores"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/user-questions/": {"get": {"tags": ["admin"], "summary": "Get User Questions Admin", "operationId": "get_user_questions_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserQuestionAdmin"}, "type": "array", "title": "Response Get User Questions Admin Admin User Questions Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["admin"], "summary": "Update User Question", "operationId": "update_user_question", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create User Question", "operationId": "create_user_question", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/user-questions/{user_question_id}": {"delete": {"tags": ["admin"], "summary": "Delete User Question", "operationId": "delete_user_question", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "user_question_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "User Question Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/user-questions/order/": {"post": {"tags": ["admin"], "summary": "Order User Questions Admin", "operationId": "order_user_questions_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/child-questions/": {"get": {"tags": ["admin"], "summary": "Get Child Questions Admin", "operationId": "get_child_questions_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildQuestionAdmin"}, "type": "array", "title": "Response Get Child Questions Admin Admin Child Questions Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["admin"], "summary": "Update Child Question", "operationId": "update_child_question", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create Child Question", "operationId": "create_child_question", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/child-questions/{child_question_id}": {"delete": {"tags": ["admin"], "summary": "Delete Child Question", "operationId": "delete_child_question", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_question_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Question Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/child-questions/order/": {"post": {"tags": ["admin"], "summary": "Order Child Questions Admin", "operationId": "order_child_questions_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/users/": {"get": {"tags": ["admin"], "summary": "Get Users", "operationId": "get_users", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserRead"}, "type": "array", "title": "Response Get Users Admin Users Get"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/me": {"get": {"tags": ["users"], "summary": "Users:Current User", "operationId": "users:current_user", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}}, "security": [{"APIKeyCookie": []}]}, "patch": {"tags": ["users"], "summary": "Users:Patch Current User", "operationId": "users:patch_current_user", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"UPDATE_USER_EMAIL_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "UPDATE_USER_EMAIL_ALREADY_EXISTS"}}, "UPDATE_USER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "UPDATE_USER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/{id}": {"get": {"tags": ["users"], "summary": "Users:User", "operationId": "users:user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["users"], "summary": "Users:Patch User", "operationId": "users:patch_user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "400": {"content": {"application/json": {"examples": {"UPDATE_USER_EMAIL_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "UPDATE_USER_EMAIL_ALREADY_EXISTS"}}, "UPDATE_USER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "UPDATE_USER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}, "schema": {"$ref": "#/components/schemas/ErrorModel"}}}, "description": "Bad Request"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Users:Delete User", "operationId": "users:delete_user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "responses": {"204": {"description": "Successful Response"}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/children/": {"get": {"tags": ["users"], "summary": "Get Children", "operationId": "get_children", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildPublic"}, "type": "array", "title": "Response Get Children Users Children Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["users"], "summary": "Update Child", "operationId": "update_child", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["users"], "summary": "Create Child", "operationId": "create_child", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildCreate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/children/{child_id}": {"get": {"tags": ["users"], "summary": "Get Child", "operationId": "get_child", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete Child", "operationId": "delete_child", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/children-images/{child_id}": {"get": {"tags": ["users"], "summary": "Get Child Image", "operationId": "get_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["users"], "summary": "Upload Child Image", "operationId": "upload_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_child_image_users_children_images__child_id__put"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete Child Image", "operationId": "delete_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers/{child_id}": {"get": {"tags": ["users"], "summary": "Get Current Milestone Answer Session", "operationId": "get_current_milestone_answer_session", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerSessionPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers/{milestone_answer_session_id}": {"put": {"tags": ["users"], "summary": "Update Milestone Answer", "operationId": "update_milestone_answer", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_answer_session_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Answer Session Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/user-answers/": {"get": {"tags": ["users"], "summary": "Get Current User Answers", "operationId": "get_current_user_answers", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "Response Get Current User Answers Users User Answers Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["users"], "summary": "Update Current User Answers", "operationId": "update_current_user_answers", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "New Answers"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "Response Update Current User Answers Users User Answers Put"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/children-answers/{child_id}": {"get": {"tags": ["users"], "summary": "Get Current Child Answers", "operationId": "get_current_child_answers", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/ChildAnswerPublic"}, "title": "Response Get Current Child Answers Users Children Answers Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["users"], "summary": "Update Current Child Answers", "operationId": "update_current_child_answers", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/ChildAnswerPublic"}, "title": "New Answers"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers-sessions/{child_id}": {"get": {"tags": ["users"], "summary": "Get Expired Milestone Answer Sessions", "operationId": "get_expired_milestone_answer_sessions", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/MilestoneAnswerSessionPublic"}, "title": "Response Get Expired Milestone Answer Sessions Users Milestone Answers Sessions Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}": {"get": {"tags": ["users"], "summary": "Get Milestonegroups For Session", "operationId": "get_milestonegroups_for_session", "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupPublic"}, "title": "Response Get Milestonegroups For Session Users Feedback Answersession Answersession Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}/milestonegroup={milestonegroup_id}/detailed": {"get": {"tags": ["users"], "summary": "Get Detailed Feedback For Milestonegroup", "operationId": "get_detailed_feedback_for_milestonegroup", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}, {"name": "milestonegroup_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestonegroup Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "integer"}, "title": "Response Get Detailed Feedback For Milestonegroup Users Feedback Answersession Answersession Id Milestonegroup Milestonegroup Id Detailed Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}/summary": {"get": {"tags": ["users"], "summary": "Get Summary Feedback For Answersession", "operationId": "get_summary_feedback_for_answersession", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "integer"}, "title": "Response Get Summary Feedback For Answersession Users Feedback Answersession Answersession Id Summary Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}/detailed": {"get": {"tags": ["users"], "summary": "Get Detailed Feedback For Answersession", "operationId": "get_detailed_feedback_for_answersession", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "object", "additionalProperties": {"type": "integer"}}, "title": "Response Get Detailed Feedback For Answersession Users Feedback Answersession Answersession Id Detailed Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/login": {"post": {"tags": ["auth"], "summary": "Auth:Cookie.Login", "operationId": "auth:cookie.login", "requestBody": {"content": {"application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/Body_auth_cookie_login_auth_login_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"LOGIN_BAD_CREDENTIALS": {"summary": "Bad credentials or the user is inactive.", "value": {"detail": "LOGIN_BAD_CREDENTIALS"}}, "LOGIN_USER_NOT_VERIFIED": {"summary": "The user is not verified.", "value": {"detail": "LOGIN_USER_NOT_VERIFIED"}}}}}}, "204": {"description": "No Content"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/logout": {"post": {"tags": ["auth"], "summary": "Auth:Cookie.Logout", "operationId": "auth:cookie.logout", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "401": {"description": "Missing token or inactive user."}, "204": {"description": "No Content"}}, "security": [{"APIKeyCookie": []}]}}, "/auth/register": {"post": {"tags": ["auth"], "summary": "Register:Register", "operationId": "register:register", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"REGISTER_USER_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "REGISTER_USER_ALREADY_EXISTS"}}, "REGISTER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "REGISTER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/forgot-password": {"post": {"tags": ["auth"], "summary": "Reset:Forgot Password", "operationId": "reset:forgot_password", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_reset_forgot_password_auth_forgot_password_post"}}}, "required": true}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/reset-password": {"post": {"tags": ["auth"], "summary": "Reset:Reset Password", "operationId": "reset:reset_password", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_reset_reset_password_auth_reset_password_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"RESET_PASSWORD_BAD_TOKEN": {"summary": "Bad or expired token.", "value": {"detail": "RESET_PASSWORD_BAD_TOKEN"}}, "RESET_PASSWORD_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "RESET_PASSWORD_INVALID_PASSWORD", "reason": "Password should be at least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/request-verify-token": {"post": {"tags": ["auth"], "summary": "Verify:Request-Token", "operationId": "verify:request-token", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_verify_request_token_auth_request_verify_token_post"}}}, "required": true}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/verify": {"post": {"tags": ["auth"], "summary": "Verify:Verify", "operationId": "verify:verify", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_verify_verify_auth_verify_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"VERIFY_USER_BAD_TOKEN": {"summary": "Bad token, not existing user ornot the e-mail currently set for the user.", "value": {"detail": "VERIFY_USER_BAD_TOKEN"}}, "VERIFY_USER_ALREADY_VERIFIED": {"summary": "The user is already verified.", "value": {"detail": "VERIFY_USER_ALREADY_VERIFIED"}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/research/auth/": {"get": {"tags": ["research"], "summary": "Auth", "operationId": "auth", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}, "security": [{"APIKeyCookie": []}]}}}, "components": {"schemas": {"Body_auth_cookie_login_auth_login_post": {"properties": {"grant_type": {"anyOf": [{"type": "string", "pattern": "password"}, {"type": "null"}], "title": "Grant Type"}, "username": {"type": "string", "title": "Username"}, "password": {"type": "string", "title": "Password"}, "scope": {"type": "string", "title": "Scope", "default": ""}, "client_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Client Id"}, "client_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Client Secret"}}, "type": "object", "required": ["username", "password"], "title": "Body_auth_cookie_login_auth_login_post"}, "Body_reset_forgot_password_auth_forgot_password_post": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}}, "type": "object", "required": ["email"], "title": "Body_reset_forgot_password_auth_forgot_password_post"}, "Body_reset_reset_password_auth_reset_password_post": {"properties": {"token": {"type": "string", "title": "Token"}, "password": {"type": "string", "title": "Password"}}, "type": "object", "required": ["token", "password"], "title": "Body_reset_reset_password_auth_reset_password_post"}, "Body_upload_child_image_users_children_images__child_id__put": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_child_image_users_children_images__child_id__put"}, "Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put"}, "Body_upload_milestone_image_admin_milestone_images__milestone_id__post": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_milestone_image_admin_milestone_images__milestone_id__post"}, "Body_verify_request_token_auth_request_verify_token_post": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}}, "type": "object", "required": ["email"], "title": "Body_verify_request_token_auth_request_verify_token_post"}, "Body_verify_verify_auth_verify_post": {"properties": {"token": {"type": "string", "title": "Token"}}, "type": "object", "required": ["token"], "title": "Body_verify_verify_auth_verify_post"}, "ChildAnswerPublic": {"properties": {"answer": {"type": "string", "title": "Answer"}, "additional_answer": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Additional Answer"}, "question_id": {"type": "integer", "title": "Question Id"}}, "type": "object", "required": ["answer", "additional_answer", "question_id"], "title": "ChildAnswerPublic"}, "ChildCreate": {"properties": {"name": {"type": "string", "title": "Name", "default": ""}, "birth_year": {"type": "integer", "title": "Birth Year"}, "birth_month": {"type": "integer", "title": "Birth Month"}}, "type": "object", "required": ["birth_year", "birth_month"], "title": "ChildCreate"}, "ChildPublic": {"properties": {"name": {"type": "string", "title": "Name", "default": ""}, "birth_year": {"type": "integer", "title": "Birth Year"}, "birth_month": {"type": "integer", "title": "Birth Month"}, "id": {"type": "integer", "title": "Id"}, "has_image": {"type": "boolean", "title": "Has Image"}}, "type": "object", "required": ["birth_year", "birth_month", "id", "has_image"], "title": "ChildPublic"}, "ChildQuestionAdmin": {"properties": {"order": {"type": "integer", "title": "Order", "default": 0}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "options": {"type": "string", "title": "Options", "default": ""}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}, "id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/ChildQuestionText"}, "type": "object", "title": "Text", "default": {}}}, "type": "object", "required": ["id"], "title": "ChildQuestionAdmin"}, "ChildQuestionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/QuestionTextPublic"}, "type": "object", "title": "Text", "default": {}}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}}, "type": "object", "required": ["id"], "title": "ChildQuestionPublic"}, "ChildQuestionText": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}, "child_question_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Child Question Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "ChildQuestionText"}, "ErrorModel": {"properties": {"detail": {"anyOf": [{"type": "string"}, {"additionalProperties": {"type": "string"}, "type": "object"}], "title": "Detail"}}, "type": "object", "required": ["detail"], "title": "ErrorModel"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "ItemOrder": {"properties": {"id": {"type": "integer", "title": "Id"}, "order": {"type": "integer", "title": "Order"}}, "type": "object", "required": ["id", "order"], "title": "ItemOrder"}, "Language": {"properties": {"id": {"type": "string", "maxLength": 2, "title": "Id"}}, "type": "object", "required": ["id"], "title": "Language"}, "MilestoneAdmin": {"properties": {"id": {"type": "integer", "title": "Id"}, "group_id": {"type": "integer", "title": "Group Id"}, "order": {"type": "integer", "title": "Order"}, "expected_age_months": {"type": "integer", "title": "Expected Age Months"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneText"}, "type": "object", "title": "Text"}, "images": {"items": {"$ref": "#/components/schemas/MilestoneImage"}, "type": "array", "title": "Images"}}, "type": "object", "required": ["id", "group_id", "order", "expected_age_months", "text", "images"], "title": "MilestoneAdmin"}, "MilestoneAgeScore": {"properties": {"milestone_id": {"type": "integer", "title": "Milestone Id"}, "age_months": {"type": "integer", "title": "Age Months"}, "avg_score": {"type": "number", "title": "Avg Score"}, "stddev_score": {"type": "number", "title": "Stddev Score"}, "expected_score": {"type": "number", "title": "Expected Score"}}, "type": "object", "required": ["milestone_id", "age_months", "avg_score", "stddev_score", "expected_score"], "title": "MilestoneAgeScore"}, "MilestoneAgeScores": {"properties": {"scores": {"items": {"$ref": "#/components/schemas/MilestoneAgeScore"}, "type": "array", "title": "Scores"}, "expected_age": {"type": "integer", "title": "Expected Age"}}, "type": "object", "required": ["scores", "expected_age"], "title": "MilestoneAgeScores"}, "MilestoneAnswerPublic": {"properties": {"milestone_id": {"type": "integer", "title": "Milestone Id"}, "answer": {"type": "integer", "title": "Answer"}}, "type": "object", "required": ["milestone_id", "answer"], "title": "MilestoneAnswerPublic"}, "MilestoneAnswerSessionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "child_id": {"type": "integer", "title": "Child Id"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "answers": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}, "type": "object", "title": "Answers"}}, "type": "object", "required": ["id", "child_id", "created_at", "answers"], "title": "MilestoneAnswerSessionPublic"}, "MilestoneGroupAdmin": {"properties": {"id": {"type": "integer", "title": "Id"}, "order": {"type": "integer", "title": "Order"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupText"}, "type": "object", "title": "Text"}, "milestones": {"items": {"$ref": "#/components/schemas/MilestoneAdmin"}, "type": "array", "title": "Milestones"}}, "type": "object", "required": ["id", "order", "text", "milestones"], "title": "MilestoneGroupAdmin"}, "MilestoneGroupPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupTextPublic"}, "type": "object", "title": "Text"}, "milestones": {"items": {"$ref": "#/components/schemas/MilestonePublic"}, "type": "array", "title": "Milestones"}}, "type": "object", "required": ["id", "text", "milestones"], "title": "MilestoneGroupPublic"}, "MilestoneGroupText": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "group_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Group Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "MilestoneGroupText"}, "MilestoneGroupTextPublic": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}}, "type": "object", "title": "MilestoneGroupTextPublic"}, "MilestoneImage": {"properties": {"id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Id"}, "milestone_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Milestone Id"}}, "type": "object", "title": "MilestoneImage"}, "MilestoneImagePublic": {"properties": {"id": {"type": "integer", "title": "Id"}}, "type": "object", "required": ["id"], "title": "MilestoneImagePublic"}, "MilestonePublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "expected_age_months": {"type": "integer", "title": "Expected Age Months"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneTextPublic"}, "type": "object", "title": "Text"}, "images": {"items": {"$ref": "#/components/schemas/MilestoneImagePublic"}, "type": "array", "title": "Images"}}, "type": "object", "required": ["id", "expected_age_months", "text", "images"], "title": "MilestonePublic"}, "MilestoneText": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "obs": {"type": "string", "title": "Obs", "default": ""}, "help": {"type": "string", "title": "Help", "default": ""}, "milestone_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Milestone Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "MilestoneText"}, "MilestoneTextPublic": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "obs": {"type": "string", "title": "Obs", "default": ""}, "help": {"type": "string", "title": "Help", "default": ""}}, "type": "object", "title": "MilestoneTextPublic"}, "QuestionTextPublic": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}}, "type": "object", "title": "QuestionTextPublic"}, "UserAnswerPublic": {"properties": {"answer": {"type": "string", "title": "Answer"}, "additional_answer": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Additional Answer"}, "question_id": {"type": "integer", "title": "Question Id"}}, "type": "object", "required": ["answer", "additional_answer", "question_id"], "title": "UserAnswerPublic"}, "UserCreate": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}, "password": {"type": "string", "title": "Password"}, "is_active": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Active", "default": true}, "is_superuser": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Superuser", "default": false}, "is_verified": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Verified", "default": false}, "is_researcher": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Researcher", "default": false}}, "type": "object", "required": ["email", "password"], "title": "UserCreate"}, "UserQuestionAdmin": {"properties": {"order": {"type": "integer", "title": "Order", "default": 0}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "options": {"type": "string", "title": "Options", "default": ""}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}, "id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/UserQuestionText"}, "type": "object", "title": "Text", "default": {}}}, "type": "object", "required": ["id"], "title": "UserQuestionAdmin"}, "UserQuestionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/QuestionTextPublic"}, "type": "object", "title": "Text", "default": {}}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}}, "type": "object", "required": ["id"], "title": "UserQuestionPublic"}, "UserQuestionText": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}, "user_question_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "User Question Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "UserQuestionText"}, "UserRead": {"properties": {"id": {"type": "integer", "title": "Id"}, "email": {"type": "string", "format": "email", "title": "Email"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "is_superuser": {"type": "boolean", "title": "Is Superuser", "default": false}, "is_verified": {"type": "boolean", "title": "Is Verified", "default": false}, "is_researcher": {"type": "boolean", "title": "Is Researcher"}}, "type": "object", "required": ["id", "email", "is_researcher"], "title": "UserRead"}, "UserUpdate": {"properties": {"password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Password"}, "email": {"anyOf": [{"type": "string", "format": "email"}, {"type": "null"}], "title": "Email"}, "is_active": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Active"}, "is_superuser": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Superuser"}, "is_verified": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Verified"}, "is_researcher": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Researcher"}}, "type": "object", "title": "UserUpdate"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}, "securitySchemes": {"APIKeyCookie": {"type": "apiKey", "in": "cookie", "name": "fastapiusersauth"}}}} \ No newline at end of file diff --git a/mondey_backend/src/mondey_backend/routers/users.py b/mondey_backend/src/mondey_backend/routers/users.py index d07c4e75..975f7a5e 100644 --- a/mondey_backend/src/mondey_backend/routers/users.py +++ b/mondey_backend/src/mondey_backend/routers/users.py @@ -16,6 +16,7 @@ from ..models.milestones import MilestoneAnswerPublic from ..models.milestones import MilestoneAnswerSession from ..models.milestones import MilestoneAnswerSessionPublic +from ..models.milestones import MilestoneGroup from ..models.milestones import MilestoneGroupPublic from ..models.questions import ChildAnswer from ..models.questions import ChildAnswerPublic @@ -273,7 +274,7 @@ def get_milestonegroups_for_session( return get_milestonegroups_for_answersession(session, answersession) @router.get( - "/feedback/answersession={answersession_id}/milestonegroup={milestonegroup_id}/details", + "/feedback/answersession={answersession_id}/milestonegroup={milestonegroup_id}/detailed", response_model=dict[int, int], ) def get_detailed_feedback_for_milestonegroup( @@ -283,7 +284,7 @@ def get_detailed_feedback_for_milestonegroup( milestonegroup_id: int, ) -> dict[int, int]: answersession = get(session, MilestoneAnswerSession, answersession_id) - m = get(session, MilestoneGroupPublic, milestonegroup_id) + m = get(session, MilestoneGroup, milestonegroup_id) answers = [ answersession.answers[ms.id] for ms in m.milestones diff --git a/mondey_backend/tests/routers/test_users.py b/mondey_backend/tests/routers/test_users.py index 6b5bcf45..10cc3f3c 100644 --- a/mondey_backend/tests/routers/test_users.py +++ b/mondey_backend/tests/routers/test_users.py @@ -349,7 +349,7 @@ def test_update_current_child_answers_no_prexisting( assert response.status_code == 404 -def test_get_summary_feedback_for_session(session, user_client: TestClient): +def test_get_summary_feedback_for_session(user_client: TestClient): response = user_client.get("/users/feedback/answersession=1/summary") assert response.status_code == 200 assert response.json() == {"1": 0} @@ -360,7 +360,7 @@ def test_get_summary_feedback_for_session_invalid_user(public_client: TestClient assert response.status_code == 401 -def test_get_detailed_feedback_for_session(session, user_client: TestClient): +def test_get_detailed_feedback_for_session(user_client: TestClient): response = user_client.get("/users/feedback/answersession=1/detailed") assert response.status_code == 200 assert response.json() == {"1": {"1": 0, "2": 0}} @@ -369,3 +369,11 @@ def test_get_detailed_feedback_for_session(session, user_client: TestClient): def test_get_detailed_feedback_for_session_invalid_user(public_client: TestClient): response = public_client.get("/users/feedback/answersession=1/detailed") assert response.status_code == 401 + + +def test_get_detailed_feedback_for_milestonegroup(user_client: TestClient): + response = user_client.get( + "users/feedback/answersession=1/milestonegroup=1/detailed" + ) + assert response.status_code == 200 + assert response.json() == {"1": 0, "2": 0} From 4393d1a23ebd802e402e61cfc1edc5731808ae01 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 25 Nov 2024 14:40:07 +0100 Subject: [PATCH 36/49] add remark about filtering --- mondey_backend/src/mondey_backend/routers/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index c98854a5..0e60b752 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -273,6 +273,7 @@ def calculate_milestonegroup_statistics( answers = [] for milestone in milestonegroup.milestones: # we want something that is relevant for the age of the child at hand. Hence we filter by age here. Is this what they want? + # FIXME: 11-25-2024: I think this is not what we want and it should be filtered by the age of the child at the time of the answer session? m_answers = [ answer.answer for answer in session.exec( From eac7c5103a4e3a175c51d66d3680e78c6bd18506 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 25 Nov 2024 14:40:45 +0100 Subject: [PATCH 37/49] improve comment --- mondey_backend/src/mondey_backend/routers/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index 0e60b752..ed9ed6c9 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -274,6 +274,7 @@ def calculate_milestonegroup_statistics( for milestone in milestonegroup.milestones: # we want something that is relevant for the age of the child at hand. Hence we filter by age here. Is this what they want? # FIXME: 11-25-2024: I think this is not what we want and it should be filtered by the age of the child at the time of the answer session? + # At any rate the above comment is obsolete m_answers = [ answer.answer for answer in session.exec( From 4b3dce7df9c6e91126c16a60e393d9f1b5ce7819 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 25 Nov 2024 15:06:54 +0100 Subject: [PATCH 38/49] work on a better design for the feedback component --- .../lib/components/ChildrenFeedback.svelte | 105 ++++++++++-------- frontend/src/locales/de.json | 3 +- .../src/mondey_backend/routers/utils.py | 4 +- 3 files changed, 66 insertions(+), 46 deletions(-) diff --git a/frontend/src/lib/components/ChildrenFeedback.svelte b/frontend/src/lib/components/ChildrenFeedback.svelte index 6be11bb2..f1e461fd 100644 --- a/frontend/src/lib/components/ChildrenFeedback.svelte +++ b/frontend/src/lib/components/ChildrenFeedback.svelte @@ -17,6 +17,8 @@ import { Accordion, AccordionItem, Button, + Checkbox, + Heading, Hr, Popover, Spinner, @@ -26,6 +28,7 @@ import { import { BellActiveSolid, + CalendarWeekSolid, ChartLineUpOutline, CheckCircleSolid, ExclamationCircleSolid, @@ -44,6 +47,8 @@ let answerSessions = $state({} as Record); let feedbackPerAnswersession = $state({} as Record); let milestoneGroups = $state({} as Record); let sessionkeys = $state([] as number[]); +let showHistory = $state(false); + const breadcrumbdata: any[] = [ { label: currentChild.name, @@ -80,7 +85,6 @@ async function setup(): Promise { answerSessions = responseAnswerSessions.data; sessionkeys = Object.keys(answerSessions) .sort() - .reverse() .map((x) => Number(x)); for (const aid of Object.keys(answerSessions)) { @@ -213,6 +217,7 @@ const promise = setup(); {/snippet} + {#if showAlert} @@ -227,51 +232,63 @@ const promise = setup();

{$_("childData.loadingMessage")}

{:then} -
- + {$_("milestone.feedbackTitle")} + {$_("milestone.showHistory")} + +
+ {#each sessionkeys as aid} - - - - {@render evaluation(feedbackPerAnswersession[aid])} - - -
- - - {#each Object.entries(feedbackPerAnswersession[aid]) as [mid, score]} - {#await getDetailed(aid, mid)} -

{$_("childData.loadingMessage")}

- {:then detailed} - - -

- {$_("milestone.milestoneGroup") } - {milestoneGroups[aid][Number(mid)].text[$locale as string].title}

-
- {@render evaluation(score as number, true)} - -
- -
- {#each Object.entries(detailed) as [ms_id, ms_score]} - {@render detailedEvaluation( - milestoneGroups[aid][Number(mid)].milestones.find((element: any) => element.id === Number(ms_id)), - ms_score - )} - {/each} -
-
- {:catch error} - - {/await} - {/each} -
-
+ {#if showHistory === true || aid === sessionkeys[sessionkeys.length -1]} + + +
+
+ +
+ +
+
+ + {@render evaluation(feedbackPerAnswersession[aid])} + + +
+ + + {#each Object.entries(feedbackPerAnswersession[aid]) as [mid, score]} + {#await getDetailed(aid, mid)} +

{$_("childData.loadingMessage")}

+ {:then detailed} + + +

+ {$_("milestone.milestoneGroup") } + {milestoneGroups[aid][Number(mid)].text[$locale as string].title}

+
+ {@render evaluation(score as number, true)} + +
+ +
+ {#each Object.entries(detailed) as [ms_id, ms_score]} + {@render detailedEvaluation( + milestoneGroups[aid][Number(mid)].milestones.find((element: any) => element.id === Number(ms_id)), + ms_score + )} + {/each} +
+
+ {:catch error} + + {/await} + {/each} +
+
+ {/if} {/each}
diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index d2dfc56f..0715a268 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -19,7 +19,8 @@ "alertMessageError": "Ein Fehler ist aufgetreten", "feedbackTitle": "Feedback zur Entwicklung", "milestone": "Meilenstein", - "milestoneGroup": "Meilensteingruppe" + "milestoneGroup": "Meilensteingruppe", + "showHistory": "Feedback zu vergangenen BeobachtungszeitrƤumen anzeigen" }, "search": { "allLabel": "Alle", diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index ed9ed6c9..3b810473 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -274,7 +274,9 @@ def calculate_milestonegroup_statistics( for milestone in milestonegroup.milestones: # we want something that is relevant for the age of the child at hand. Hence we filter by age here. Is this what they want? # FIXME: 11-25-2024: I think this is not what we want and it should be filtered by the age of the child at the time of the answer session? - # At any rate the above comment is obsolete + # this however should already be handled by the answersession itself? + # dazed and confused.... + # At any rate the above comment is obsolete. m_answers = [ answer.answer for answer in session.exec( From 4bde87483fe011645eb7cb81a80dac032f0da383 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 25 Nov 2024 17:20:16 +0100 Subject: [PATCH 39/49] add steps to feedback scale --- .../lib/components/ChildrenFeedback.svelte | 75 ++++++++++--------- frontend/src/locales/de.json | 6 +- .../src/mondey_backend/models/milestones.py | 10 +-- .../src/mondey_backend/routers/scores.py | 22 ++++-- mondey_backend/tests/utils/test_scores.py | 6 +- 5 files changed, 62 insertions(+), 57 deletions(-) diff --git a/frontend/src/lib/components/ChildrenFeedback.svelte b/frontend/src/lib/components/ChildrenFeedback.svelte index f1e461fd..b74a7c9d 100644 --- a/frontend/src/lib/components/ChildrenFeedback.svelte +++ b/frontend/src/lib/components/ChildrenFeedback.svelte @@ -25,17 +25,16 @@ import { Timeline, TimelineItem, } from "flowbite-svelte"; - import { BellActiveSolid, CalendarWeekSolid, ChartLineUpOutline, CheckCircleSolid, + CloseCircleSolid, ExclamationCircleSolid, + EyeSolid, UserSettingsOutline, } from "flowbite-svelte-icons"; - -import {} from "flowbite-svelte-icons"; import { _, locale } from "svelte-i18n"; import AlertMessage from "./AlertMessage.svelte"; @@ -118,12 +117,12 @@ async function setup(): Promise { async function getDetailed( aid: number, - mid: string, + mid: number, ): Promise> { const response = await getDetailedFeedbackForMilestonegroup({ path: { answersession_id: aid, - milestonegroup_id: Number(mid), + milestonegroup_id: mid, }, }); @@ -156,59 +155,66 @@ function evaluate(v: number): number { } function summarizeFeedback(feedback: Record | number): number { - // get minimum score if (typeof feedback === "number") { - return evaluate(feedback); + return feedback; } - let minscore = 1; - for (const score of Object.values(feedback)) { - if (score < minscore) { - minscore = score; - } - } - return evaluate(minscore); -} + const minscore = Math.min(...Object.values(feedback)); + return minscore; +} +// FIXME: add something that explains the scale! const promise = setup(); {#snippet evaluation(value: Record | number, with_text: boolean = true)} -
- {#if summarizeFeedback(value) === 1} +
+ {#if summarizeFeedback(value) === 2} + {#if with_text === true} +

{$_("childData.recommendOk")}

+ {/if} + {:else if summarizeFeedback(value) === 1} {#if with_text === true} -

{$_("childData.recommendOk")}

+

{$_("childData.recommendOkWithCaveat")}

{/if} - + {:else if summarizeFeedback(value) === 0} - {#if with_text === true} -

{$_("childData.recommendWatch")}

+

{$_("childData.recommendWatch")}

+ {/if} + + {:else if summarizeFeedback(value) === -1} + {#if with_text === true} +

{$_("childData.recommendWatchWithCaveat")}

{/if} - - {:else} + {:else} {#if with_text === true} -

{$_("childData.recommmendHelp")}

+

{$_("childData.recommmendHelp")}

{/if} + {/if}
{/snippet} {#snippet detailedEvaluation(milestone: MilestonePublic, ms_score: number, )} -
-

{$_("milestone.milestone")} {milestone.text[$locale as string].title}:

+
+ {TODO: adjust to 5 scale!} {#if evaluate(ms_score) === 1} +

{milestone.text[$locale as string].title}

+ {:else if evaluate(ms_score) === 0} +

{milestone.text[$locale as string].title}

{milestone.text[$locale as string].help} {:else} +

{milestone.text[$locale as string].title}

{milestone.text[$locale as string].help} @@ -238,7 +244,6 @@ const promise = setup();
{#each sessionkeys as aid} - {#if showHistory === true || aid === sessionkeys[sessionkeys.length -1]} @@ -257,23 +262,21 @@ const promise = setup(); {#each Object.entries(feedbackPerAnswersession[aid]) as [mid, score]} - {#await getDetailed(aid, mid)} + {#await getDetailed(aid, Number(mid))}

{$_("childData.loadingMessage")}

{:then detailed} - -

- {$_("milestone.milestoneGroup") } - {milestoneGroups[aid][Number(mid)].text[$locale as string].title}

-
- {@render evaluation(score as number, true)} - + + + {milestoneGroups[aid][mid].text[$locale as string].title} + + {@render evaluation( score as number, false)}
{#each Object.entries(detailed) as [ms_id, ms_score]} {@render detailedEvaluation( - milestoneGroups[aid][Number(mid)].milestones.find((element: any) => element.id === Number(ms_id)), + milestoneGroups[aid][mid].milestones.find((element: any) => element.id === Number(ms_id)), ms_score )} {/each} diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 0715a268..665e1f93 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -20,7 +20,7 @@ "feedbackTitle": "Feedback zur Entwicklung", "milestone": "Meilenstein", "milestoneGroup": "Meilensteingruppe", - "showHistory": "Feedback zu vergangenen BeobachtungszeitrƤumen anzeigen" + "showHistory": "Feedback zu vorherigen BeobachtungszeitrƤumen anzeigen" }, "search": { "allLabel": "Alle", @@ -172,8 +172,10 @@ "noFileChosen": "Keine Datei ausgewƤhlt", "deleteImageButton": "Bild lƶschen", "recommendOk": "Entwicklung des Kindes ist altersgemƤƟ", + "recommendOkWithCaveat": "Entwicklung des Kindes ist altersgemƤƟ, aber einzelne Meilensteine sollten besonders beobachtet und eventuell gefƶrdert werden", "recommendWatch": "Entwicklung des Kindes sollte beobachtet werden", - "recommmendHelp": "Entwicklung des Kindes sollte gefƶrdert werden" + "recommendWatchWithCaveat": "Entwicklung des Kindes ist nicht ganz altersgemƤƟ und sollte beobachtet werden. Einzelne Bereiche sollten besonders gefƶrdert werden", + "recommmendHelp": "Entwicklung des Kindes ist mƶglicherweise verzƶgert und sollte gefƶrdert werden." }, "forgotPw": { "heading": "Passwort vergessen?", diff --git a/mondey_backend/src/mondey_backend/models/milestones.py b/mondey_backend/src/mondey_backend/models/milestones.py index 1380bae8..03ae43e9 100644 --- a/mondey_backend/src/mondey_backend/models/milestones.py +++ b/mondey_backend/src/mondey_backend/models/milestones.py @@ -186,15 +186,7 @@ class MilestoneAgeScores(BaseModel): expected_age: int -class MilestoneScore(SQLModel, table=True): - child_id: int = Field(default=None, foreign_key="child.id", primary_key=True) - milestone_id: int = Field( - default=None, foreign_key="milestone.id", primary_key=True - ) - score: float - - -class MilestoneGroupStatistics(SQLModel, table=True): +class MilestoneGroupStatistics(SQLModel): session_id: int = Field( default=None, foreign_key="milestoneanswersession.id", primary_key=True ) diff --git a/mondey_backend/src/mondey_backend/routers/scores.py b/mondey_backend/src/mondey_backend/routers/scores.py index 1dcf0be5..7062b945 100644 --- a/mondey_backend/src/mondey_backend/routers/scores.py +++ b/mondey_backend/src/mondey_backend/routers/scores.py @@ -29,14 +29,18 @@ class TrafficLight(Enum): """ - invalid = -2 - red = -1 + invalid = -3 + red = -2 + yellowWithCaveat = -1 yellow = 0 - green = 1 + greenWithCaveat = 1 + green = 2 def compute_feedback_simple( - stat: MilestoneAgeScore | MilestoneGroupStatistics, score: float + stat: MilestoneAgeScore | MilestoneGroupStatistics, + score: float, + min_score: float | None = None, ) -> int: """ Compute trafficlight feedback. Replace this function with your own if you @@ -58,14 +62,13 @@ def compute_feedback_simple( def leq(val: float, lim: float) -> bool: return val < lim or np.isclose(val, lim) - # TODO: what happens if the average if stat.stddev_score < 1e-2: # README: This happens when all the scores are the same, so any # deviation towards lower values can be interpreted as # underperformance. # This logic relies on the score being integers, such that when the # stddev is 0, the avg is an integer - # TODO: Check again what client wants to happen in such cases + # TODO: Check again what client wants to happen in such cases? lim_lower = stat.avg_score - 2 lim_upper = stat.avg_score - 1 else: @@ -75,8 +78,12 @@ def leq(val: float, lim: float) -> bool: if leq(score, lim_lower): return TrafficLight.red.value elif score > lim_lower and leq(score, lim_upper): + if min_score is not None and min_score < lim_lower: + return TrafficLight.yellowWithCaveat.value return TrafficLight.yellow.value else: + if min_score is not None and min_score < lim_upper: + return TrafficLight.greenWithCaveat.value return TrafficLight.green.value @@ -165,8 +172,9 @@ def compute_summary_milestonegroup_feedback_for_answersession( mg_stat.child_id = child.id # type: ignore mean_for_mg = np.nan_to_num(np.mean([a.answer for a in answers])) + min_for_mg = np.nan_to_num(np.min([a.answer for a in answers])) - result = compute_feedback_simple(mg_stat, mean_for_mg) + result = compute_feedback_simple(mg_stat, mean_for_mg, min_for_mg) milestone_group_results[milestonegroup_id] = result # type: ignore return milestone_group_results diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_scores.py index 4c4dbba3..7c1330a6 100644 --- a/mondey_backend/tests/utils/test_scores.py +++ b/mondey_backend/tests/utils/test_scores.py @@ -43,13 +43,13 @@ def test_compute_feedback_simple(): expected_score=1.0, ) score = 0 - assert compute_feedback_simple(dummy_scores, score) == -1 + assert compute_feedback_simple(dummy_scores, score) == -2 score = 1 assert compute_feedback_simple(dummy_scores, score) == 0 score = 3 - assert compute_feedback_simple(dummy_scores, score) == 1 + assert compute_feedback_simple(dummy_scores, score) == 2 def test_compute_detailed_milestonegroup_feedback_for_answersession(session): @@ -60,7 +60,7 @@ def test_compute_detailed_milestonegroup_feedback_for_answersession(session): session, answersession, child ) - assert result == {1: {1: 1, 2: 1}} # FIXME: check this again + assert result == {1: {1: 2, 2: 2}} def test_compute_detailed_milestonegroup_feedback_for_answersession_no_data(session): From 49afb5a54db411d9c092766fe9ba1c9629ac92d5 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Mon, 25 Nov 2024 21:22:09 +0100 Subject: [PATCH 40/49] add legend, better text --- .../lib/components/ChildrenFeedback.svelte | 91 ++++++++++++------- frontend/src/locales/de.json | 13 +-- 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/frontend/src/lib/components/ChildrenFeedback.svelte b/frontend/src/lib/components/ChildrenFeedback.svelte index b74a7c9d..89e2b599 100644 --- a/frontend/src/lib/components/ChildrenFeedback.svelte +++ b/frontend/src/lib/components/ChildrenFeedback.svelte @@ -1,39 +1,39 @@ {#await promise} -
-

{$_("userData.loadingMessage")}

-
{:then}
Date: Wed, 11 Dec 2024 21:34:10 +0100 Subject: [PATCH 49/49] merge in backend from main --- mondey_backend/openapi.json | 2 +- mondey_backend/pyproject.toml | 5 +- .../src/mondey_backend/models/milestones.py | 69 +++- .../src/mondey_backend/models/research.py | 8 + .../src/mondey_backend/models/users.py | 10 + .../routers/admin_routers/milestones.py | 22 +- .../routers/admin_routers/users.py | 54 ++- .../src/mondey_backend/routers/milestones.py | 19 +- .../src/mondey_backend/routers/scores.py | 332 +++++++-------- .../src/mondey_backend/routers/statistics.py | 389 ++++++++++++++++++ .../src/mondey_backend/routers/users.py | 73 +--- .../src/mondey_backend/routers/utils.py | 163 ++------ mondey_backend/src/mondey_backend/settings.py | 1 + mondey_backend/src/mondey_backend/users.py | 50 ++- mondey_backend/tests/conftest.py | 235 +++++++++-- .../admin_routers/test_admin_milestones.py | 30 +- .../routers/admin_routers/test_admin_users.py | 27 ++ mondey_backend/tests/routers/test_auth.py | 75 ++++ .../tests/routers/test_milestones.py | 1 + mondey_backend/tests/routers/test_users.py | 42 +- mondey_backend/tests/utils/test_scores.py | 213 +++++----- mondey_backend/tests/utils/test_statistics.py | 282 +++++++++++++ mondey_backend/tests/utils/test_utils.py | 115 +----- 23 files changed, 1521 insertions(+), 696 deletions(-) create mode 100644 mondey_backend/src/mondey_backend/models/research.py create mode 100644 mondey_backend/src/mondey_backend/routers/statistics.py create mode 100644 mondey_backend/tests/routers/test_auth.py create mode 100644 mondey_backend/tests/utils/test_statistics.py diff --git a/mondey_backend/openapi.json b/mondey_backend/openapi.json index ed09fe69..bd6943c9 100644 --- a/mondey_backend/openapi.json +++ b/mondey_backend/openapi.json @@ -1 +1 @@ -{"openapi": "3.1.0", "info": {"title": "MONDEY API", "version": "0.1.0"}, "paths": {"/languages/": {"get": {"tags": ["milestones"], "summary": "Get Languages", "operationId": "get_languages", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"type": "string"}, "type": "array", "title": "Response Get Languages Languages Get"}}}}}}}, "/milestones/": {"get": {"tags": ["milestones"], "summary": "Get Milestones", "operationId": "get_milestones", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/MilestonePublic"}, "type": "array", "title": "Response Get Milestones Milestones Get"}}}}}}}, "/milestones/{milestone_id}": {"get": {"tags": ["milestones"], "summary": "Get Milestone", "operationId": "get_milestone", "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestonePublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/milestone-groups/{child_id}": {"get": {"tags": ["milestones"], "summary": "Get Milestone Groups", "operationId": "get_milestone_groups", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/MilestoneGroupPublic"}, "title": "Response Get Milestone Groups Milestone Groups Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/submitted-milestone-images/{milestone_id}": {"post": {"tags": ["milestones"], "summary": "Submit Milestone Image", "operationId": "submit_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_submit_milestone_image_submitted_milestone_images__milestone_id__post"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/user-questions/": {"get": {"tags": ["questions"], "summary": "Get User Questions", "operationId": "get_user_questions", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserQuestionPublic"}, "type": "array", "title": "Response Get User Questions User Questions Get"}}}}}}}, "/child-questions/": {"get": {"tags": ["questions"], "summary": "Get Child Questions", "operationId": "get_child_questions", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildQuestionPublic"}, "type": "array", "title": "Response Get Child Questions Child Questions Get"}}}}}}}, "/admin/languages/": {"post": {"tags": ["admin"], "summary": "Create Language", "operationId": "create_language", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Language"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Language"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/languages/{language_id}": {"delete": {"tags": ["admin"], "summary": "Delete Language", "operationId": "delete_language", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "language_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Language Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/i18n/{language_id}": {"put": {"tags": ["admin"], "summary": "Update I18N", "operationId": "update_i18n", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "language_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Language Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "object", "additionalProperties": {"type": "string"}}, "title": "I18Dict"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-groups/": {"get": {"tags": ["admin"], "summary": "Get Milestone Groups Admin", "operationId": "get_milestone_groups_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}, "type": "array", "title": "Response Get Milestone Groups Admin Admin Milestone Groups Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create Milestone Group Admin", "operationId": "create_milestone_group_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-groups": {"put": {"tags": ["admin"], "summary": "Update Milestone Group Admin", "operationId": "update_milestone_group_admin", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-groups/{milestone_group_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone Group Admin", "operationId": "delete_milestone_group_admin", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-groups/order/": {"post": {"tags": ["admin"], "summary": "Order Milestone Groups Admin", "operationId": "order_milestone_groups_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-group-images/{milestone_group_id}": {"put": {"tags": ["admin"], "summary": "Upload Milestone Group Image", "operationId": "upload_milestone_group_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/{milestone_group_id}": {"post": {"tags": ["admin"], "summary": "Create Milestone", "operationId": "create_milestone", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/": {"put": {"tags": ["admin"], "summary": "Update Milestone", "operationId": "update_milestone", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestones/{milestone_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone", "operationId": "delete_milestone", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/order/": {"post": {"tags": ["admin"], "summary": "Order Milestones Admin", "operationId": "order_milestones_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-images/{milestone_id}": {"post": {"tags": ["admin"], "summary": "Upload Milestone Image", "operationId": "upload_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_milestone_image_admin_milestone_images__milestone_id__post"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneImage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-images/{milestone_image_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone Image", "operationId": "delete_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_image_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Image Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/submitted-milestone-images/": {"get": {"tags": ["admin"], "summary": "Get Submitted Milestone Images", "operationId": "get_submitted_milestone_images", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/SubmittedMilestoneImagePublic"}, "type": "array", "title": "Response Get Submitted Milestone Images Admin Submitted Milestone Images Get"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/submitted-milestone-images/approve/{submitted_milestone_image_id}": {"post": {"tags": ["admin"], "summary": "Approve Submitted Milestone Image", "operationId": "approve_submitted_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "submitted_milestone_image_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Submitted Milestone Image Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/submitted-milestone-images/{submitted_milestone_image_id}": {"delete": {"tags": ["admin"], "summary": "Delete Submitted Milestone Image", "operationId": "delete_submitted_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "submitted_milestone_image_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Submitted Milestone Image Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-age-scores/{milestone_id}": {"get": {"tags": ["admin"], "summary": "Get Milestone Age Scores", "operationId": "get_milestone_age_scores", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAgeScores"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/user-questions/": {"get": {"tags": ["admin"], "summary": "Get User Questions Admin", "operationId": "get_user_questions_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserQuestionAdmin"}, "type": "array", "title": "Response Get User Questions Admin Admin User Questions Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["admin"], "summary": "Update User Question", "operationId": "update_user_question", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create User Question", "operationId": "create_user_question", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/user-questions/{user_question_id}": {"delete": {"tags": ["admin"], "summary": "Delete User Question", "operationId": "delete_user_question", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "user_question_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "User Question Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/user-questions/order/": {"post": {"tags": ["admin"], "summary": "Order User Questions Admin", "operationId": "order_user_questions_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/child-questions/": {"get": {"tags": ["admin"], "summary": "Get Child Questions Admin", "operationId": "get_child_questions_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildQuestionAdmin"}, "type": "array", "title": "Response Get Child Questions Admin Admin Child Questions Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["admin"], "summary": "Update Child Question", "operationId": "update_child_question", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create Child Question", "operationId": "create_child_question", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/child-questions/{child_question_id}": {"delete": {"tags": ["admin"], "summary": "Delete Child Question", "operationId": "delete_child_question", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_question_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Question Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/child-questions/order/": {"post": {"tags": ["admin"], "summary": "Order Child Questions Admin", "operationId": "order_child_questions_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/users/": {"get": {"tags": ["admin"], "summary": "Get Users", "operationId": "get_users", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserRead"}, "type": "array", "title": "Response Get Users Admin Users Get"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/me": {"get": {"tags": ["users"], "summary": "Users:Current User", "operationId": "users:current_user", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}}, "security": [{"APIKeyCookie": []}]}, "patch": {"tags": ["users"], "summary": "Users:Patch Current User", "operationId": "users:patch_current_user", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"UPDATE_USER_EMAIL_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "UPDATE_USER_EMAIL_ALREADY_EXISTS"}}, "UPDATE_USER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "UPDATE_USER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/{id}": {"get": {"tags": ["users"], "summary": "Users:User", "operationId": "users:user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["users"], "summary": "Users:Patch User", "operationId": "users:patch_user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "400": {"content": {"application/json": {"examples": {"UPDATE_USER_EMAIL_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "UPDATE_USER_EMAIL_ALREADY_EXISTS"}}, "UPDATE_USER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "UPDATE_USER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}, "schema": {"$ref": "#/components/schemas/ErrorModel"}}}, "description": "Bad Request"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Users:Delete User", "operationId": "users:delete_user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "responses": {"204": {"description": "Successful Response"}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/children/": {"get": {"tags": ["users"], "summary": "Get Children", "operationId": "get_children", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildPublic"}, "type": "array", "title": "Response Get Children Users Children Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["users"], "summary": "Update Child", "operationId": "update_child", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["users"], "summary": "Create Child", "operationId": "create_child", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildCreate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/children/{child_id}": {"get": {"tags": ["users"], "summary": "Get Child", "operationId": "get_child", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete Child", "operationId": "delete_child", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/children-images/{child_id}": {"get": {"tags": ["users"], "summary": "Get Child Image", "operationId": "get_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["users"], "summary": "Upload Child Image", "operationId": "upload_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_child_image_users_children_images__child_id__put"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete Child Image", "operationId": "delete_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers/{child_id}": {"get": {"tags": ["users"], "summary": "Get Current Milestone Answer Session", "operationId": "get_current_milestone_answer_session", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerSessionPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers/{milestone_answer_session_id}": {"put": {"tags": ["users"], "summary": "Update Milestone Answer", "operationId": "update_milestone_answer", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_answer_session_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Answer Session Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/user-answers/": {"get": {"tags": ["users"], "summary": "Get Current User Answers", "operationId": "get_current_user_answers", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "Response Get Current User Answers Users User Answers Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["users"], "summary": "Update Current User Answers", "operationId": "update_current_user_answers", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "New Answers"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "Response Update Current User Answers Users User Answers Put"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/children-answers/{child_id}": {"get": {"tags": ["users"], "summary": "Get Current Child Answers", "operationId": "get_current_child_answers", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/ChildAnswerPublic"}, "title": "Response Get Current Child Answers Users Children Answers Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["users"], "summary": "Update Current Child Answers", "operationId": "update_current_child_answers", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/ChildAnswerPublic"}, "title": "New Answers"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers-sessions/{child_id}": {"get": {"tags": ["users"], "summary": "Get Expired Milestone Answer Sessions", "operationId": "get_expired_milestone_answer_sessions", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/MilestoneAnswerSessionPublic"}, "title": "Response Get Expired Milestone Answer Sessions Users Milestone Answers Sessions Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}": {"get": {"tags": ["users"], "summary": "Get Milestonegroups For Session", "operationId": "get_milestonegroups_for_session", "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupPublic"}, "title": "Response Get Milestonegroups For Session Users Feedback Answersession Answersession Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}/milestonegroup={milestonegroup_id}/detailed": {"get": {"tags": ["users"], "summary": "Get Detailed Feedback For Milestonegroup", "operationId": "get_detailed_feedback_for_milestonegroup", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}, {"name": "milestonegroup_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestonegroup Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "integer"}, "title": "Response Get Detailed Feedback For Milestonegroup Users Feedback Answersession Answersession Id Milestonegroup Milestonegroup Id Detailed Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}/summary": {"get": {"tags": ["users"], "summary": "Get Summary Feedback For Answersession", "operationId": "get_summary_feedback_for_answersession", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "integer"}, "title": "Response Get Summary Feedback For Answersession Users Feedback Answersession Answersession Id Summary Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}/detailed": {"get": {"tags": ["users"], "summary": "Get Detailed Feedback For Answersession", "operationId": "get_detailed_feedback_for_answersession", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "object", "additionalProperties": {"type": "integer"}}, "title": "Response Get Detailed Feedback For Answersession Users Feedback Answersession Answersession Id Detailed Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/login": {"post": {"tags": ["auth"], "summary": "Auth:Cookie.Login", "operationId": "auth:cookie.login", "requestBody": {"content": {"application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/Body_auth_cookie_login_auth_login_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"LOGIN_BAD_CREDENTIALS": {"summary": "Bad credentials or the user is inactive.", "value": {"detail": "LOGIN_BAD_CREDENTIALS"}}, "LOGIN_USER_NOT_VERIFIED": {"summary": "The user is not verified.", "value": {"detail": "LOGIN_USER_NOT_VERIFIED"}}}}}}, "204": {"description": "No Content"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/logout": {"post": {"tags": ["auth"], "summary": "Auth:Cookie.Logout", "operationId": "auth:cookie.logout", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "401": {"description": "Missing token or inactive user."}, "204": {"description": "No Content"}}, "security": [{"APIKeyCookie": []}]}}, "/auth/register": {"post": {"tags": ["auth"], "summary": "Register:Register", "operationId": "register:register", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"REGISTER_USER_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "REGISTER_USER_ALREADY_EXISTS"}}, "REGISTER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "REGISTER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/forgot-password": {"post": {"tags": ["auth"], "summary": "Reset:Forgot Password", "operationId": "reset:forgot_password", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_reset_forgot_password_auth_forgot_password_post"}}}, "required": true}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/reset-password": {"post": {"tags": ["auth"], "summary": "Reset:Reset Password", "operationId": "reset:reset_password", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_reset_reset_password_auth_reset_password_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"RESET_PASSWORD_BAD_TOKEN": {"summary": "Bad or expired token.", "value": {"detail": "RESET_PASSWORD_BAD_TOKEN"}}, "RESET_PASSWORD_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "RESET_PASSWORD_INVALID_PASSWORD", "reason": "Password should be at least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/request-verify-token": {"post": {"tags": ["auth"], "summary": "Verify:Request-Token", "operationId": "verify:request-token", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_verify_request_token_auth_request_verify_token_post"}}}, "required": true}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/verify": {"post": {"tags": ["auth"], "summary": "Verify:Verify", "operationId": "verify:verify", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_verify_verify_auth_verify_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"VERIFY_USER_BAD_TOKEN": {"summary": "Bad token, not existing user ornot the e-mail currently set for the user.", "value": {"detail": "VERIFY_USER_BAD_TOKEN"}}, "VERIFY_USER_ALREADY_VERIFIED": {"summary": "The user is already verified.", "value": {"detail": "VERIFY_USER_ALREADY_VERIFIED"}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/research/auth/": {"get": {"tags": ["research"], "summary": "Auth", "operationId": "auth", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}, "security": [{"APIKeyCookie": []}]}}}, "components": {"schemas": {"Body_auth_cookie_login_auth_login_post": {"properties": {"grant_type": {"anyOf": [{"type": "string", "pattern": "password"}, {"type": "null"}], "title": "Grant Type"}, "username": {"type": "string", "title": "Username"}, "password": {"type": "string", "title": "Password"}, "scope": {"type": "string", "title": "Scope", "default": ""}, "client_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Client Id"}, "client_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Client Secret"}}, "type": "object", "required": ["username", "password"], "title": "Body_auth_cookie_login_auth_login_post"}, "Body_reset_forgot_password_auth_forgot_password_post": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}}, "type": "object", "required": ["email"], "title": "Body_reset_forgot_password_auth_forgot_password_post"}, "Body_reset_reset_password_auth_reset_password_post": {"properties": {"token": {"type": "string", "title": "Token"}, "password": {"type": "string", "title": "Password"}}, "type": "object", "required": ["token", "password"], "title": "Body_reset_reset_password_auth_reset_password_post"}, "Body_submit_milestone_image_submitted_milestone_images__milestone_id__post": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_submit_milestone_image_submitted_milestone_images__milestone_id__post"}, "Body_upload_child_image_users_children_images__child_id__put": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_child_image_users_children_images__child_id__put"}, "Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put"}, "Body_upload_milestone_image_admin_milestone_images__milestone_id__post": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_milestone_image_admin_milestone_images__milestone_id__post"}, "Body_verify_request_token_auth_request_verify_token_post": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}}, "type": "object", "required": ["email"], "title": "Body_verify_request_token_auth_request_verify_token_post"}, "Body_verify_verify_auth_verify_post": {"properties": {"token": {"type": "string", "title": "Token"}}, "type": "object", "required": ["token"], "title": "Body_verify_verify_auth_verify_post"}, "ChildAnswerPublic": {"properties": {"answer": {"type": "string", "title": "Answer"}, "additional_answer": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Additional Answer"}, "question_id": {"type": "integer", "title": "Question Id"}}, "type": "object", "required": ["answer", "additional_answer", "question_id"], "title": "ChildAnswerPublic"}, "ChildCreate": {"properties": {"name": {"type": "string", "title": "Name", "default": ""}, "birth_year": {"type": "integer", "title": "Birth Year"}, "birth_month": {"type": "integer", "title": "Birth Month"}}, "type": "object", "required": ["birth_year", "birth_month"], "title": "ChildCreate"}, "ChildPublic": {"properties": {"name": {"type": "string", "title": "Name", "default": ""}, "birth_year": {"type": "integer", "title": "Birth Year"}, "birth_month": {"type": "integer", "title": "Birth Month"}, "id": {"type": "integer", "title": "Id"}, "has_image": {"type": "boolean", "title": "Has Image"}}, "type": "object", "required": ["birth_year", "birth_month", "id", "has_image"], "title": "ChildPublic"}, "ChildQuestionAdmin": {"properties": {"order": {"type": "integer", "title": "Order", "default": 0}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "options": {"type": "string", "title": "Options", "default": ""}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}, "id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/ChildQuestionText"}, "type": "object", "title": "Text", "default": {}}}, "type": "object", "required": ["id"], "title": "ChildQuestionAdmin"}, "ChildQuestionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/QuestionTextPublic"}, "type": "object", "title": "Text", "default": {}}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}}, "type": "object", "required": ["id"], "title": "ChildQuestionPublic"}, "ChildQuestionText": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}, "child_question_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Child Question Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "ChildQuestionText"}, "ErrorModel": {"properties": {"detail": {"anyOf": [{"type": "string"}, {"additionalProperties": {"type": "string"}, "type": "object"}], "title": "Detail"}}, "type": "object", "required": ["detail"], "title": "ErrorModel"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "ItemOrder": {"properties": {"id": {"type": "integer", "title": "Id"}, "order": {"type": "integer", "title": "Order"}}, "type": "object", "required": ["id", "order"], "title": "ItemOrder"}, "Language": {"properties": {"id": {"type": "string", "maxLength": 2, "title": "Id"}}, "type": "object", "required": ["id"], "title": "Language"}, "MilestoneAdmin": {"properties": {"id": {"type": "integer", "title": "Id"}, "group_id": {"type": "integer", "title": "Group Id"}, "order": {"type": "integer", "title": "Order"}, "expected_age_months": {"type": "integer", "title": "Expected Age Months"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneText"}, "type": "object", "title": "Text"}, "images": {"items": {"$ref": "#/components/schemas/MilestoneImage"}, "type": "array", "title": "Images"}}, "type": "object", "required": ["id", "group_id", "order", "expected_age_months", "text", "images"], "title": "MilestoneAdmin"}, "MilestoneAgeScore": {"properties": {"milestone_id": {"type": "integer", "title": "Milestone Id"}, "age_months": {"type": "integer", "title": "Age Months"}, "avg_score": {"type": "number", "title": "Avg Score"}, "stddev_score": {"type": "number", "title": "Stddev Score"}, "expected_score": {"type": "number", "title": "Expected Score"}}, "type": "object", "required": ["milestone_id", "age_months", "avg_score", "stddev_score", "expected_score"], "title": "MilestoneAgeScore"}, "MilestoneAgeScores": {"properties": {"scores": {"items": {"$ref": "#/components/schemas/MilestoneAgeScore"}, "type": "array", "title": "Scores"}, "expected_age": {"type": "integer", "title": "Expected Age"}}, "type": "object", "required": ["scores", "expected_age"], "title": "MilestoneAgeScores"}, "MilestoneAnswerPublic": {"properties": {"milestone_id": {"type": "integer", "title": "Milestone Id"}, "answer": {"type": "integer", "title": "Answer"}}, "type": "object", "required": ["milestone_id", "answer"], "title": "MilestoneAnswerPublic"}, "MilestoneAnswerSessionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "child_id": {"type": "integer", "title": "Child Id"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "answers": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}, "type": "object", "title": "Answers"}}, "type": "object", "required": ["id", "child_id", "created_at", "answers"], "title": "MilestoneAnswerSessionPublic"}, "MilestoneGroupAdmin": {"properties": {"id": {"type": "integer", "title": "Id"}, "order": {"type": "integer", "title": "Order"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupText"}, "type": "object", "title": "Text"}, "milestones": {"items": {"$ref": "#/components/schemas/MilestoneAdmin"}, "type": "array", "title": "Milestones"}}, "type": "object", "required": ["id", "order", "text", "milestones"], "title": "MilestoneGroupAdmin"}, "MilestoneGroupPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupTextPublic"}, "type": "object", "title": "Text"}, "milestones": {"items": {"$ref": "#/components/schemas/MilestonePublic"}, "type": "array", "title": "Milestones"}}, "type": "object", "required": ["id", "text", "milestones"], "title": "MilestoneGroupPublic"}, "MilestoneGroupText": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "group_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Group Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "MilestoneGroupText"}, "MilestoneGroupTextPublic": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}}, "type": "object", "title": "MilestoneGroupTextPublic"}, "MilestoneImage": {"properties": {"id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Id"}, "milestone_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Milestone Id"}}, "type": "object", "title": "MilestoneImage"}, "MilestoneImagePublic": {"properties": {"id": {"type": "integer", "title": "Id"}}, "type": "object", "required": ["id"], "title": "MilestoneImagePublic"}, "MilestonePublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "expected_age_months": {"type": "integer", "title": "Expected Age Months"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneTextPublic"}, "type": "object", "title": "Text"}, "images": {"items": {"$ref": "#/components/schemas/MilestoneImagePublic"}, "type": "array", "title": "Images"}}, "type": "object", "required": ["id", "expected_age_months", "text", "images"], "title": "MilestonePublic"}, "MilestoneText": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "obs": {"type": "string", "title": "Obs", "default": ""}, "help": {"type": "string", "title": "Help", "default": ""}, "milestone_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Milestone Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "MilestoneText"}, "MilestoneTextPublic": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "obs": {"type": "string", "title": "Obs", "default": ""}, "help": {"type": "string", "title": "Help", "default": ""}}, "type": "object", "title": "MilestoneTextPublic"}, "QuestionTextPublic": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}}, "type": "object", "title": "QuestionTextPublic"}, "SubmittedMilestoneImagePublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "milestone_id": {"type": "integer", "title": "Milestone Id"}, "user_id": {"type": "integer", "title": "User Id"}}, "type": "object", "required": ["id", "milestone_id", "user_id"], "title": "SubmittedMilestoneImagePublic"}, "UserAnswerPublic": {"properties": {"answer": {"type": "string", "title": "Answer"}, "additional_answer": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Additional Answer"}, "question_id": {"type": "integer", "title": "Question Id"}}, "type": "object", "required": ["answer", "additional_answer", "question_id"], "title": "UserAnswerPublic"}, "UserCreate": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}, "password": {"type": "string", "title": "Password"}, "is_active": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Active", "default": true}, "is_superuser": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Superuser", "default": false}, "is_verified": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Verified", "default": false}, "is_researcher": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Researcher", "default": false}}, "type": "object", "required": ["email", "password"], "title": "UserCreate"}, "UserQuestionAdmin": {"properties": {"order": {"type": "integer", "title": "Order", "default": 0}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "options": {"type": "string", "title": "Options", "default": ""}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}, "id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/UserQuestionText"}, "type": "object", "title": "Text", "default": {}}}, "type": "object", "required": ["id"], "title": "UserQuestionAdmin"}, "UserQuestionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/QuestionTextPublic"}, "type": "object", "title": "Text", "default": {}}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}}, "type": "object", "required": ["id"], "title": "UserQuestionPublic"}, "UserQuestionText": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}, "user_question_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "User Question Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "UserQuestionText"}, "UserRead": {"properties": {"id": {"type": "integer", "title": "Id"}, "email": {"type": "string", "format": "email", "title": "Email"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "is_superuser": {"type": "boolean", "title": "Is Superuser", "default": false}, "is_verified": {"type": "boolean", "title": "Is Verified", "default": false}, "is_researcher": {"type": "boolean", "title": "Is Researcher"}}, "type": "object", "required": ["id", "email", "is_researcher"], "title": "UserRead"}, "UserUpdate": {"properties": {"password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Password"}, "email": {"anyOf": [{"type": "string", "format": "email"}, {"type": "null"}], "title": "Email"}, "is_active": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Active"}, "is_superuser": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Superuser"}, "is_verified": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Verified"}, "is_researcher": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Researcher"}}, "type": "object", "title": "UserUpdate"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}, "securitySchemes": {"APIKeyCookie": {"type": "apiKey", "in": "cookie", "name": "fastapiusersauth"}}}} \ No newline at end of file +{"openapi": "3.1.0", "info": {"title": "MONDEY API", "version": "0.1.0"}, "paths": {"/languages/": {"get": {"tags": ["milestones"], "summary": "Get Languages", "operationId": "get_languages", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"type": "string"}, "type": "array", "title": "Response Get Languages Languages Get"}}}}}}}, "/milestones/": {"get": {"tags": ["milestones"], "summary": "Get Milestones", "operationId": "get_milestones", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/MilestonePublic"}, "type": "array", "title": "Response Get Milestones Milestones Get"}}}}}}}, "/milestones/{milestone_id}": {"get": {"tags": ["milestones"], "summary": "Get Milestone", "operationId": "get_milestone", "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestonePublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/milestone-groups/{child_id}": {"get": {"tags": ["milestones"], "summary": "Get Milestone Groups", "operationId": "get_milestone_groups", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/MilestoneGroupPublic"}, "title": "Response Get Milestone Groups Milestone Groups Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/submitted-milestone-images/{milestone_id}": {"post": {"tags": ["milestones"], "summary": "Submit Milestone Image", "operationId": "submit_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_submit_milestone_image_submitted_milestone_images__milestone_id__post"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/user-questions/": {"get": {"tags": ["questions"], "summary": "Get User Questions", "operationId": "get_user_questions", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserQuestionPublic"}, "type": "array", "title": "Response Get User Questions User Questions Get"}}}}}}}, "/child-questions/": {"get": {"tags": ["questions"], "summary": "Get Child Questions", "operationId": "get_child_questions", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildQuestionPublic"}, "type": "array", "title": "Response Get Child Questions Child Questions Get"}}}}}}}, "/admin/languages/": {"post": {"tags": ["admin"], "summary": "Create Language", "operationId": "create_language", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Language"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Language"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/languages/{language_id}": {"delete": {"tags": ["admin"], "summary": "Delete Language", "operationId": "delete_language", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "language_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Language Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/i18n/{language_id}": {"put": {"tags": ["admin"], "summary": "Update I18N", "operationId": "update_i18n", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "language_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Language Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "object", "additionalProperties": {"type": "string"}}, "title": "I18Dict"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-groups/": {"get": {"tags": ["admin"], "summary": "Get Milestone Groups Admin", "operationId": "get_milestone_groups_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}, "type": "array", "title": "Response Get Milestone Groups Admin Admin Milestone Groups Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create Milestone Group Admin", "operationId": "create_milestone_group_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-groups": {"put": {"tags": ["admin"], "summary": "Update Milestone Group Admin", "operationId": "update_milestone_group_admin", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneGroupAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-groups/{milestone_group_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone Group Admin", "operationId": "delete_milestone_group_admin", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-groups/order/": {"post": {"tags": ["admin"], "summary": "Order Milestone Groups Admin", "operationId": "order_milestone_groups_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-group-images/{milestone_group_id}": {"put": {"tags": ["admin"], "summary": "Upload Milestone Group Image", "operationId": "upload_milestone_group_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/{milestone_group_id}": {"post": {"tags": ["admin"], "summary": "Create Milestone", "operationId": "create_milestone", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/": {"put": {"tags": ["admin"], "summary": "Update Milestone", "operationId": "update_milestone", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestones/{milestone_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone", "operationId": "delete_milestone", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestones/order/": {"post": {"tags": ["admin"], "summary": "Order Milestones Admin", "operationId": "order_milestones_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/milestone-images/{milestone_id}": {"post": {"tags": ["admin"], "summary": "Upload Milestone Image", "operationId": "upload_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_milestone_image_admin_milestone_images__milestone_id__post"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneImage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-images/{milestone_image_id}": {"delete": {"tags": ["admin"], "summary": "Delete Milestone Image", "operationId": "delete_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_image_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Image Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/submitted-milestone-images/": {"get": {"tags": ["admin"], "summary": "Get Submitted Milestone Images", "operationId": "get_submitted_milestone_images", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/SubmittedMilestoneImagePublic"}, "type": "array", "title": "Response Get Submitted Milestone Images Admin Submitted Milestone Images Get"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/submitted-milestone-images/approve/{submitted_milestone_image_id}": {"post": {"tags": ["admin"], "summary": "Approve Submitted Milestone Image", "operationId": "approve_submitted_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "submitted_milestone_image_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Submitted Milestone Image Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/submitted-milestone-images/{submitted_milestone_image_id}": {"delete": {"tags": ["admin"], "summary": "Delete Submitted Milestone Image", "operationId": "delete_submitted_milestone_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "submitted_milestone_image_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Submitted Milestone Image Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/milestone-age-scores/{milestone_id}": {"get": {"tags": ["admin"], "summary": "Get Milestone Age Scores", "operationId": "get_milestone_age_scores", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAgeScoreCollectionPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/user-questions/": {"get": {"tags": ["admin"], "summary": "Get User Questions Admin", "operationId": "get_user_questions_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserQuestionAdmin"}, "type": "array", "title": "Response Get User Questions Admin Admin User Questions Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["admin"], "summary": "Update User Question", "operationId": "update_user_question", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create User Question", "operationId": "create_user_question", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserQuestionAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/user-questions/{user_question_id}": {"delete": {"tags": ["admin"], "summary": "Delete User Question", "operationId": "delete_user_question", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "user_question_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "User Question Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/user-questions/order/": {"post": {"tags": ["admin"], "summary": "Order User Questions Admin", "operationId": "order_user_questions_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/child-questions/": {"get": {"tags": ["admin"], "summary": "Get Child Questions Admin", "operationId": "get_child_questions_admin", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildQuestionAdmin"}, "type": "array", "title": "Response Get Child Questions Admin Admin Child Questions Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["admin"], "summary": "Update Child Question", "operationId": "update_child_question", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["admin"], "summary": "Create Child Question", "operationId": "create_child_question", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildQuestionAdmin"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/child-questions/{child_question_id}": {"delete": {"tags": ["admin"], "summary": "Delete Child Question", "operationId": "delete_child_question", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_question_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Question Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/child-questions/order/": {"post": {"tags": ["admin"], "summary": "Order Child Questions Admin", "operationId": "order_child_questions_admin", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ItemOrder"}, "type": "array", "title": "Item Orders"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/users/": {"get": {"tags": ["admin"], "summary": "Get Users", "operationId": "get_users", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserRead"}, "type": "array", "title": "Response Get Users Admin Users Get"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/research-groups/": {"get": {"tags": ["admin"], "summary": "Get Research Groups", "operationId": "get_research_groups", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ResearchGroup"}, "type": "array", "title": "Response Get Research Groups Admin Research Groups Get"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/admin/research-groups/{user_id}": {"post": {"tags": ["admin"], "summary": "Create Research Group", "operationId": "create_research_group", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "User Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResearchGroup"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/admin/research-groups/{research_group_id}": {"delete": {"tags": ["admin"], "summary": "Delete Research Group", "operationId": "delete_research_group", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "research_group_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Research Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/me": {"get": {"tags": ["users"], "summary": "Users:Current User", "operationId": "users:current_user", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}}, "security": [{"APIKeyCookie": []}]}, "patch": {"tags": ["users"], "summary": "Users:Patch Current User", "operationId": "users:patch_current_user", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"UPDATE_USER_EMAIL_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "UPDATE_USER_EMAIL_ALREADY_EXISTS"}}, "UPDATE_USER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "UPDATE_USER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/{id}": {"get": {"tags": ["users"], "summary": "Users:User", "operationId": "users:user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["users"], "summary": "Users:Patch User", "operationId": "users:patch_user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "400": {"content": {"application/json": {"examples": {"UPDATE_USER_EMAIL_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "UPDATE_USER_EMAIL_ALREADY_EXISTS"}}, "UPDATE_USER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "UPDATE_USER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}, "schema": {"$ref": "#/components/schemas/ErrorModel"}}}, "description": "Bad Request"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Users:Delete User", "operationId": "users:delete_user", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}], "responses": {"204": {"description": "Successful Response"}, "401": {"description": "Missing token or inactive user."}, "403": {"description": "Not a superuser."}, "404": {"description": "The user does not exist."}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/children/": {"get": {"tags": ["users"], "summary": "Get Children", "operationId": "get_children", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ChildPublic"}, "type": "array", "title": "Response Get Children Users Children Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["users"], "summary": "Update Child", "operationId": "update_child", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}, "post": {"tags": ["users"], "summary": "Create Child", "operationId": "create_child", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildCreate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/children/{child_id}": {"get": {"tags": ["users"], "summary": "Get Child", "operationId": "get_child", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChildPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete Child", "operationId": "delete_child", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/children-images/{child_id}": {"get": {"tags": ["users"], "summary": "Get Child Image", "operationId": "get_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["users"], "summary": "Upload Child Image", "operationId": "upload_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_upload_child_image_users_children_images__child_id__put"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete Child Image", "operationId": "delete_child_image", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers/{child_id}": {"get": {"tags": ["users"], "summary": "Get Current Milestone Answer Session", "operationId": "get_current_milestone_answer_session", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerSessionPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers/{milestone_answer_session_id}": {"put": {"tags": ["users"], "summary": "Update Milestone Answer", "operationId": "update_milestone_answer", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "milestone_answer_session_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Milestone Answer Session Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/user-answers/": {"get": {"tags": ["users"], "summary": "Get Current User Answers", "operationId": "get_current_user_answers", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "Response Get Current User Answers Users User Answers Get"}}}}}, "security": [{"APIKeyCookie": []}]}, "put": {"tags": ["users"], "summary": "Update Current User Answers", "operationId": "update_current_user_answers", "requestBody": {"content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "New Answers"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserAnswerPublic"}, "type": "array", "title": "Response Update Current User Answers Users User Answers Put"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"APIKeyCookie": []}]}}, "/users/children-answers/{child_id}": {"get": {"tags": ["users"], "summary": "Get Current Child Answers", "operationId": "get_current_child_answers", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/ChildAnswerPublic"}, "title": "Response Get Current Child Answers Users Children Answers Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["users"], "summary": "Update Current Child Answers", "operationId": "update_current_child_answers", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/ChildAnswerPublic"}, "title": "New Answers"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/milestone-answers-sessions/{child_id}": {"get": {"tags": ["users"], "summary": "Get Expired Milestone Answer Sessions", "operationId": "get_expired_milestone_answer_sessions", "security": [{"APIKeyCookie": []}], "parameters": [{"name": "child_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Child Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/MilestoneAnswerSessionPublic"}, "title": "Response Get Expired Milestone Answer Sessions Users Milestone Answers Sessions Child Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}": {"get": {"tags": ["users"], "summary": "Get Milestonegroups For Session", "operationId": "get_milestonegroups_for_session", "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupPublic"}, "title": "Response Get Milestonegroups For Session Users Feedback Answersession Answersession Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}/summary": {"get": {"tags": ["users"], "summary": "Get Summary Feedback For Answersession", "operationId": "get_summary_feedback_for_answersession", "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "integer"}, "title": "Response Get Summary Feedback For Answersession Users Feedback Answersession Answersession Id Summary Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/users/feedback/answersession={answersession_id}/detailed": {"get": {"tags": ["users"], "summary": "Get Detailed Feedback For Answersession", "operationId": "get_detailed_feedback_for_answersession", "parameters": [{"name": "answersession_id", "in": "path", "required": true, "schema": {"type": "integer", "title": "Answersession Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "object", "additionalProperties": {"type": "integer"}}, "title": "Response Get Detailed Feedback For Answersession Users Feedback Answersession Answersession Id Detailed Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/login": {"post": {"tags": ["auth"], "summary": "Auth:Cookie.Login", "operationId": "auth:cookie.login", "requestBody": {"content": {"application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/Body_auth_cookie_login_auth_login_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"LOGIN_BAD_CREDENTIALS": {"summary": "Bad credentials or the user is inactive.", "value": {"detail": "LOGIN_BAD_CREDENTIALS"}}, "LOGIN_USER_NOT_VERIFIED": {"summary": "The user is not verified.", "value": {"detail": "LOGIN_USER_NOT_VERIFIED"}}}}}}, "204": {"description": "No Content"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/logout": {"post": {"tags": ["auth"], "summary": "Auth:Cookie.Logout", "operationId": "auth:cookie.logout", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "401": {"description": "Missing token or inactive user."}, "204": {"description": "No Content"}}, "security": [{"APIKeyCookie": []}]}}, "/auth/register": {"post": {"tags": ["auth"], "summary": "Register:Register", "operationId": "register:register", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"REGISTER_USER_ALREADY_EXISTS": {"summary": "A user with this email already exists.", "value": {"detail": "REGISTER_USER_ALREADY_EXISTS"}}, "REGISTER_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "REGISTER_INVALID_PASSWORD", "reason": "Password should beat least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/forgot-password": {"post": {"tags": ["auth"], "summary": "Reset:Forgot Password", "operationId": "reset:forgot_password", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_reset_forgot_password_auth_forgot_password_post"}}}, "required": true}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/reset-password": {"post": {"tags": ["auth"], "summary": "Reset:Reset Password", "operationId": "reset:reset_password", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_reset_reset_password_auth_reset_password_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"RESET_PASSWORD_BAD_TOKEN": {"summary": "Bad or expired token.", "value": {"detail": "RESET_PASSWORD_BAD_TOKEN"}}, "RESET_PASSWORD_INVALID_PASSWORD": {"summary": "Password validation failed.", "value": {"detail": {"code": "RESET_PASSWORD_INVALID_PASSWORD", "reason": "Password should be at least 3 characters"}}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/request-verify-token": {"post": {"tags": ["auth"], "summary": "Verify:Request-Token", "operationId": "verify:request-token", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_verify_request_token_auth_request_verify_token_post"}}}, "required": true}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/auth/verify": {"post": {"tags": ["auth"], "summary": "Verify:Verify", "operationId": "verify:verify", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_verify_verify_auth_verify_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRead"}}}}, "400": {"description": "Bad Request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorModel"}, "examples": {"VERIFY_USER_BAD_TOKEN": {"summary": "Bad token, not existing user ornot the e-mail currently set for the user.", "value": {"detail": "VERIFY_USER_BAD_TOKEN"}}, "VERIFY_USER_ALREADY_VERIFIED": {"summary": "The user is already verified.", "value": {"detail": "VERIFY_USER_ALREADY_VERIFIED"}}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/research/auth/": {"get": {"tags": ["research"], "summary": "Auth", "operationId": "auth", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}, "security": [{"APIKeyCookie": []}]}}}, "components": {"schemas": {"Body_auth_cookie_login_auth_login_post": {"properties": {"grant_type": {"anyOf": [{"type": "string", "pattern": "password"}, {"type": "null"}], "title": "Grant Type"}, "username": {"type": "string", "title": "Username"}, "password": {"type": "string", "title": "Password"}, "scope": {"type": "string", "title": "Scope", "default": ""}, "client_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Client Id"}, "client_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Client Secret"}}, "type": "object", "required": ["username", "password"], "title": "Body_auth_cookie_login_auth_login_post"}, "Body_reset_forgot_password_auth_forgot_password_post": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}}, "type": "object", "required": ["email"], "title": "Body_reset_forgot_password_auth_forgot_password_post"}, "Body_reset_reset_password_auth_reset_password_post": {"properties": {"token": {"type": "string", "title": "Token"}, "password": {"type": "string", "title": "Password"}}, "type": "object", "required": ["token", "password"], "title": "Body_reset_reset_password_auth_reset_password_post"}, "Body_submit_milestone_image_submitted_milestone_images__milestone_id__post": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_submit_milestone_image_submitted_milestone_images__milestone_id__post"}, "Body_upload_child_image_users_children_images__child_id__put": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_child_image_users_children_images__child_id__put"}, "Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_milestone_group_image_admin_milestone_group_images__milestone_group_id__put"}, "Body_upload_milestone_image_admin_milestone_images__milestone_id__post": {"properties": {"file": {"type": "string", "format": "binary", "title": "File"}}, "type": "object", "required": ["file"], "title": "Body_upload_milestone_image_admin_milestone_images__milestone_id__post"}, "Body_verify_request_token_auth_request_verify_token_post": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}}, "type": "object", "required": ["email"], "title": "Body_verify_request_token_auth_request_verify_token_post"}, "Body_verify_verify_auth_verify_post": {"properties": {"token": {"type": "string", "title": "Token"}}, "type": "object", "required": ["token"], "title": "Body_verify_verify_auth_verify_post"}, "ChildAnswerPublic": {"properties": {"answer": {"type": "string", "title": "Answer"}, "additional_answer": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Additional Answer"}, "question_id": {"type": "integer", "title": "Question Id"}}, "type": "object", "required": ["answer", "additional_answer", "question_id"], "title": "ChildAnswerPublic"}, "ChildCreate": {"properties": {"name": {"type": "string", "title": "Name", "default": ""}, "birth_year": {"type": "integer", "title": "Birth Year"}, "birth_month": {"type": "integer", "title": "Birth Month"}}, "type": "object", "required": ["birth_year", "birth_month"], "title": "ChildCreate"}, "ChildPublic": {"properties": {"name": {"type": "string", "title": "Name", "default": ""}, "birth_year": {"type": "integer", "title": "Birth Year"}, "birth_month": {"type": "integer", "title": "Birth Month"}, "id": {"type": "integer", "title": "Id"}, "has_image": {"type": "boolean", "title": "Has Image"}}, "type": "object", "required": ["birth_year", "birth_month", "id", "has_image"], "title": "ChildPublic"}, "ChildQuestionAdmin": {"properties": {"order": {"type": "integer", "title": "Order", "default": 0}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "options": {"type": "string", "title": "Options", "default": ""}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}, "id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/ChildQuestionText"}, "type": "object", "title": "Text", "default": {}}}, "type": "object", "required": ["id"], "title": "ChildQuestionAdmin"}, "ChildQuestionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/QuestionTextPublic"}, "type": "object", "title": "Text", "default": {}}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}}, "type": "object", "required": ["id"], "title": "ChildQuestionPublic"}, "ChildQuestionText": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}, "child_question_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Child Question Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "ChildQuestionText"}, "ErrorModel": {"properties": {"detail": {"anyOf": [{"type": "string"}, {"additionalProperties": {"type": "string"}, "type": "object"}], "title": "Detail"}}, "type": "object", "required": ["detail"], "title": "ErrorModel"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "ItemOrder": {"properties": {"id": {"type": "integer", "title": "Id"}, "order": {"type": "integer", "title": "Order"}}, "type": "object", "required": ["id", "order"], "title": "ItemOrder"}, "Language": {"properties": {"id": {"type": "string", "maxLength": 2, "title": "Id"}}, "type": "object", "required": ["id"], "title": "Language"}, "MilestoneAdmin": {"properties": {"id": {"type": "integer", "title": "Id"}, "group_id": {"type": "integer", "title": "Group Id"}, "order": {"type": "integer", "title": "Order"}, "expected_age_months": {"type": "integer", "title": "Expected Age Months"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneText"}, "type": "object", "title": "Text"}, "images": {"items": {"$ref": "#/components/schemas/MilestoneImage"}, "type": "array", "title": "Images"}}, "type": "object", "required": ["id", "group_id", "order", "expected_age_months", "text", "images"], "title": "MilestoneAdmin"}, "MilestoneAgeScore": {"properties": {"milestone_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Milestone Id"}, "age": {"type": "integer", "title": "Age"}, "count": {"type": "integer", "title": "Count"}, "avg_score": {"type": "number", "title": "Avg Score"}, "stddev_score": {"type": "number", "title": "Stddev Score"}, "expected_score": {"type": "number", "title": "Expected Score"}}, "type": "object", "required": ["age", "count", "avg_score", "stddev_score", "expected_score"], "title": "MilestoneAgeScore"}, "MilestoneAgeScoreCollectionPublic": {"properties": {"milestone_id": {"type": "integer", "title": "Milestone Id"}, "expected_age": {"type": "integer", "title": "Expected Age"}, "scores": {"items": {"$ref": "#/components/schemas/MilestoneAgeScore"}, "type": "array", "title": "Scores"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}}, "type": "object", "required": ["milestone_id", "expected_age", "scores", "created_at"], "title": "MilestoneAgeScoreCollectionPublic"}, "MilestoneAnswerPublic": {"properties": {"milestone_id": {"type": "integer", "title": "Milestone Id"}, "answer": {"type": "integer", "title": "Answer"}}, "type": "object", "required": ["milestone_id", "answer"], "title": "MilestoneAnswerPublic"}, "MilestoneAnswerSessionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "child_id": {"type": "integer", "title": "Child Id"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "answers": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneAnswerPublic"}, "type": "object", "title": "Answers"}}, "type": "object", "required": ["id", "child_id", "created_at", "answers"], "title": "MilestoneAnswerSessionPublic"}, "MilestoneGroupAdmin": {"properties": {"id": {"type": "integer", "title": "Id"}, "order": {"type": "integer", "title": "Order"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupText"}, "type": "object", "title": "Text"}, "milestones": {"items": {"$ref": "#/components/schemas/MilestoneAdmin"}, "type": "array", "title": "Milestones"}}, "type": "object", "required": ["id", "order", "text", "milestones"], "title": "MilestoneGroupAdmin"}, "MilestoneGroupPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneGroupTextPublic"}, "type": "object", "title": "Text"}, "milestones": {"items": {"$ref": "#/components/schemas/MilestonePublic"}, "type": "array", "title": "Milestones"}}, "type": "object", "required": ["id", "text", "milestones"], "title": "MilestoneGroupPublic"}, "MilestoneGroupText": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "group_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Group Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "MilestoneGroupText"}, "MilestoneGroupTextPublic": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}}, "type": "object", "title": "MilestoneGroupTextPublic"}, "MilestoneImage": {"properties": {"id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Id"}, "milestone_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Milestone Id"}}, "type": "object", "title": "MilestoneImage"}, "MilestoneImagePublic": {"properties": {"id": {"type": "integer", "title": "Id"}}, "type": "object", "required": ["id"], "title": "MilestoneImagePublic"}, "MilestonePublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "expected_age_months": {"type": "integer", "title": "Expected Age Months"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/MilestoneTextPublic"}, "type": "object", "title": "Text"}, "images": {"items": {"$ref": "#/components/schemas/MilestoneImagePublic"}, "type": "array", "title": "Images"}}, "type": "object", "required": ["id", "expected_age_months", "text", "images"], "title": "MilestonePublic"}, "MilestoneText": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "obs": {"type": "string", "title": "Obs", "default": ""}, "help": {"type": "string", "title": "Help", "default": ""}, "milestone_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Milestone Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "MilestoneText"}, "MilestoneTextPublic": {"properties": {"title": {"type": "string", "title": "Title", "default": ""}, "desc": {"type": "string", "title": "Desc", "default": ""}, "obs": {"type": "string", "title": "Obs", "default": ""}, "help": {"type": "string", "title": "Help", "default": ""}}, "type": "object", "title": "MilestoneTextPublic"}, "QuestionTextPublic": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}}, "type": "object", "title": "QuestionTextPublic"}, "ResearchGroup": {"properties": {"id": {"type": "integer", "title": "Id"}}, "type": "object", "required": ["id"], "title": "ResearchGroup"}, "SubmittedMilestoneImagePublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "milestone_id": {"type": "integer", "title": "Milestone Id"}, "user_id": {"type": "integer", "title": "User Id"}}, "type": "object", "required": ["id", "milestone_id", "user_id"], "title": "SubmittedMilestoneImagePublic"}, "UserAnswerPublic": {"properties": {"answer": {"type": "string", "title": "Answer"}, "additional_answer": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Additional Answer"}, "question_id": {"type": "integer", "title": "Question Id"}}, "type": "object", "required": ["answer", "additional_answer", "question_id"], "title": "UserAnswerPublic"}, "UserCreate": {"properties": {"email": {"type": "string", "format": "email", "title": "Email"}, "password": {"type": "string", "title": "Password"}, "is_active": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Active", "default": true}, "is_superuser": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Superuser", "default": false}, "is_verified": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Verified", "default": false}, "is_researcher": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Researcher", "default": false}, "full_data_access": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Full Data Access", "default": false}, "research_group_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Research Group Id", "default": 0}}, "type": "object", "required": ["email", "password"], "title": "UserCreate"}, "UserQuestionAdmin": {"properties": {"order": {"type": "integer", "title": "Order", "default": 0}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "options": {"type": "string", "title": "Options", "default": ""}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}, "id": {"type": "integer", "title": "Id"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/UserQuestionText"}, "type": "object", "title": "Text", "default": {}}}, "type": "object", "required": ["id"], "title": "UserQuestionAdmin"}, "UserQuestionPublic": {"properties": {"id": {"type": "integer", "title": "Id"}, "component": {"type": "string", "title": "Component", "default": "select"}, "type": {"type": "string", "title": "Type", "default": "text"}, "text": {"additionalProperties": {"$ref": "#/components/schemas/QuestionTextPublic"}, "type": "object", "title": "Text", "default": {}}, "additional_option": {"type": "string", "title": "Additional Option", "default": ""}}, "type": "object", "required": ["id"], "title": "UserQuestionPublic"}, "UserQuestionText": {"properties": {"question": {"type": "string", "title": "Question", "default": ""}, "options_json": {"type": "string", "title": "Options Json", "default": ""}, "options": {"type": "string", "title": "Options", "default": ""}, "user_question_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "User Question Id"}, "lang_id": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "null"}], "title": "Lang Id"}}, "type": "object", "title": "UserQuestionText"}, "UserRead": {"properties": {"id": {"type": "integer", "title": "Id"}, "email": {"type": "string", "format": "email", "title": "Email"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "is_superuser": {"type": "boolean", "title": "Is Superuser", "default": false}, "is_verified": {"type": "boolean", "title": "Is Verified", "default": false}, "is_researcher": {"type": "boolean", "title": "Is Researcher"}, "full_data_access": {"type": "boolean", "title": "Full Data Access"}, "research_group_id": {"type": "integer", "title": "Research Group Id"}}, "type": "object", "required": ["id", "email", "is_researcher", "full_data_access", "research_group_id"], "title": "UserRead"}, "UserUpdate": {"properties": {"password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Password"}, "email": {"anyOf": [{"type": "string", "format": "email"}, {"type": "null"}], "title": "Email"}, "is_active": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Active"}, "is_superuser": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Superuser"}, "is_verified": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Verified"}, "is_researcher": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Researcher"}, "full_data_access": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Full Data Access"}, "research_group_id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Research Group Id"}}, "type": "object", "title": "UserUpdate"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}, "securitySchemes": {"APIKeyCookie": {"type": "apiKey", "in": "cookie", "name": "fastapiusersauth"}}}} \ No newline at end of file diff --git a/mondey_backend/pyproject.toml b/mondey_backend/pyproject.toml index 31a2bad0..1ab19366 100644 --- a/mondey_backend/pyproject.toml +++ b/mondey_backend/pyproject.toml @@ -18,11 +18,12 @@ dependencies = [ "aiosqlite", "python-multipart", "pydantic-settings", + "types-python-dateutil", "click", "pillow", "webp", - "python-dateutil", - "types-python-dateutil" + "python-dateutil", + "checkdigit", ] dynamic = ["version"] diff --git a/mondey_backend/src/mondey_backend/models/milestones.py b/mondey_backend/src/mondey_backend/models/milestones.py index 3f523636..c72e6377 100644 --- a/mondey_backend/src/mondey_backend/models/milestones.py +++ b/mondey_backend/src/mondey_backend/models/milestones.py @@ -2,7 +2,6 @@ import datetime -from pydantic import BaseModel from sqlalchemy.orm import Mapped from sqlmodel import Field from sqlmodel import SQLModel @@ -21,8 +20,6 @@ class Language(SQLModel, table=True): ## MilestoneGroupText - - class MilestoneGroupTextBase(SQLModel): title: str = "" desc: str = "" @@ -95,8 +92,6 @@ class MilestoneTextPublic(MilestoneTextBase): ## Milestone - - class Milestone(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) group_id: int | None = Field(default=None, foreign_key="milestonegroup.id") @@ -124,8 +119,6 @@ class MilestoneAdmin(SQLModel): ## MilestoneImage - - class MilestoneImage(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) milestone_id: int | None = Field(default=None, foreign_key="milestone.id") @@ -163,6 +156,7 @@ class MilestoneAnswer(SQLModel, table=True): milestone_id: int | None = Field( default=None, foreign_key="milestone.id", primary_key=True ) + milestone_group_id: int = Field(default=None, foreign_key="milestonegroup.id") answer: int @@ -185,27 +179,62 @@ class MilestoneAnswerSessionPublic(SQLModel): answers: dict[int, MilestoneAnswerPublic] -class MilestoneAgeScore(BaseModel): - milestone_id: int - age_months: int +# models for statistics. README: Perhaps this could be made simpler if the data was stored in a database with array-column support. sqlite apparently doesnt have this: https://stackoverflow.com/questions/3005231/how-to-store-array-in-one-column-in-sqlite3, but postgres does: https://www.postgresql.org/docs/9.1/arrays.html +# will be returned to later. Issue no. 119 +class MilestoneAgeScore(SQLModel, table=True): + milestone_id: int | None = Field( + default=None, + primary_key=True, + foreign_key="milestoneagescorecollection.milestone_id", + ) + age: int = Field(primary_key=True) + collection: MilestoneAgeScoreCollection = back_populates("scores") + count: int avg_score: float stddev_score: float expected_score: float -class MilestoneAgeScores(BaseModel): - scores: list[MilestoneAgeScore] +class MilestoneAgeScoreCollection(SQLModel, table=True): + milestone_id: int = Field( + default=None, primary_key=True, foreign_key="milestone.id" + ) expected_age: int + scores: Mapped[list[MilestoneAgeScore]] = back_populates("collection") + created_at: datetime.datetime = Field( + sa_column_kwargs={ + "server_default": text("CURRENT_TIMESTAMP"), + } + ) -class MilestoneGroupStatistics(SQLModel): - session_id: int = Field( - default=None, foreign_key="milestoneanswersession.id", primary_key=True - ) - group_id: int = Field( - default=None, foreign_key="milestonegroup.id", primary_key=True +class MilestoneAgeScoreCollectionPublic(SQLModel): + milestone_id: int + expected_age: int + scores: list[MilestoneAgeScore] + created_at: datetime.datetime + + +class MilestoneGroupAgeScore(SQLModel, table=True): + age: int | None = Field(default=None, primary_key=True) + milestone_group_id: int | None = Field( + default=None, + primary_key=True, + foreign_key="milestonegroupagescorecollection.milestone_group_id", ) - child_id: int = Field(default=None, foreign_key="child.id", primary_key=True) - age_months: int + collection: MilestoneGroupAgeScoreCollection = back_populates("scores") + count: int avg_score: float stddev_score: float + + +class MilestoneGroupAgeScoreCollection(SQLModel, table=True): + milestone_group_id: int = Field( + default=None, primary_key=True, foreign_key="milestonegroup.id" + ) + scores: Mapped[list[MilestoneGroupAgeScore]] = back_populates("collection") + created_at: datetime.datetime = Field( + sa_column_kwargs={ + "server_default": text("CURRENT_TIMESTAMP"), + } + ) diff --git a/mondey_backend/src/mondey_backend/models/research.py b/mondey_backend/src/mondey_backend/models/research.py new file mode 100644 index 00000000..592d7faf --- /dev/null +++ b/mondey_backend/src/mondey_backend/models/research.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from sqlmodel import Field +from sqlmodel import SQLModel + + +class ResearchGroup(SQLModel, table=True): + id: int = Field(primary_key=True) diff --git a/mondey_backend/src/mondey_backend/models/users.py b/mondey_backend/src/mondey_backend/models/users.py index dbccc141..bad4fb24 100644 --- a/mondey_backend/src/mondey_backend/models/users.py +++ b/mondey_backend/src/mondey_backend/models/users.py @@ -19,18 +19,28 @@ class Base(DeclarativeBase): class User(SQLAlchemyBaseUserTable[int], Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) is_researcher: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + full_data_access: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False + ) + research_group_id: Mapped[int] = mapped_column(Integer, default=0, nullable=False) class UserRead(schemas.BaseUser[int]): is_researcher: bool + full_data_access: bool + research_group_id: int class UserCreate(schemas.BaseUserCreate): is_researcher: bool | None = False + full_data_access: bool | None = False + research_group_id: int | None = 0 class UserUpdate(schemas.BaseUserUpdate): is_researcher: bool | None = None + full_data_access: bool | None = None + research_group_id: int | None = None class AccessToken(SQLAlchemyBaseAccessTokenTable[int], Base): diff --git a/mondey_backend/src/mondey_backend/routers/admin_routers/milestones.py b/mondey_backend/src/mondey_backend/routers/admin_routers/milestones.py index 754fe5a4..8dde8167 100644 --- a/mondey_backend/src/mondey_backend/routers/admin_routers/milestones.py +++ b/mondey_backend/src/mondey_backend/routers/admin_routers/milestones.py @@ -1,6 +1,7 @@ from __future__ import annotations from fastapi import APIRouter +from fastapi import HTTPException from fastapi import UploadFile from sqlmodel import col from sqlmodel import select @@ -9,7 +10,8 @@ from ...models.milestones import Language from ...models.milestones import Milestone from ...models.milestones import MilestoneAdmin -from ...models.milestones import MilestoneAgeScores +from ...models.milestones import MilestoneAgeScoreCollection +from ...models.milestones import MilestoneAgeScoreCollectionPublic from ...models.milestones import MilestoneGroup from ...models.milestones import MilestoneGroupAdmin from ...models.milestones import MilestoneGroupText @@ -19,7 +21,6 @@ from ...models.milestones import SubmittedMilestoneImagePublic from ...models.utils import ItemOrder from ..utils import add -from ..utils import calculate_milestone_statistics_by_age from ..utils import get from ..utils import milestone_group_image_path from ..utils import milestone_image_path @@ -177,10 +178,21 @@ async def delete_submitted_milestone_image( session.commit() return {"ok": True} - @router.get("/milestone-age-scores/{milestone_id}") + @router.get( + "/milestone-age-scores/{milestone_id}", + response_model=MilestoneAgeScoreCollectionPublic, + ) def get_milestone_age_scores( session: SessionDep, milestone_id: int - ) -> MilestoneAgeScores: - return calculate_milestone_statistics_by_age(session, milestone_id) + ) -> MilestoneAgeScoreCollection: + collection = get(session, MilestoneAgeScoreCollection, milestone_id) + + if collection is None: + raise HTTPException( + 404, + detail='"No milestone age score collection with id: ", milestone_id', + ) + + return collection return router diff --git a/mondey_backend/src/mondey_backend/routers/admin_routers/users.py b/mondey_backend/src/mondey_backend/routers/admin_routers/users.py index 5522c51c..4f47aa54 100644 --- a/mondey_backend/src/mondey_backend/routers/admin_routers/users.py +++ b/mondey_backend/src/mondey_backend/routers/admin_routers/users.py @@ -1,19 +1,69 @@ from __future__ import annotations +from random import randint + +from checkdigit import verhoeff from fastapi import APIRouter +from fastapi import HTTPException from sqlmodel import select +from ...dependencies import SessionDep from ...dependencies import UserAsyncSessionDep +from ...models.research import ResearchGroup from ...models.users import User from ...models.users import UserRead +from ..utils import add +from ..utils import get + + +def generate_research_group_id() -> int: + """Generate a possible 6-digit integer research group ID with a checksum.""" + code = randint(10000, 99999) + checksum = int(verhoeff.calculate(str(code))) + return code * 10 + checksum + + +def generate_research_group(session: SessionDep) -> ResearchGroup: + research_group_id = generate_research_group_id() + while session.get(ResearchGroup, research_group_id) is not None: + research_group_id = generate_research_group_id() + research_group = ResearchGroup(id=research_group_id) + add(session, research_group) + return research_group def create_router() -> APIRouter: router = APIRouter() @router.get("/users/", response_model=list[UserRead]) - async def get_users(session: UserAsyncSessionDep): - users = await session.execute(select(User)) + async def get_users(user_session: UserAsyncSessionDep): + users = await user_session.execute(select(User)) return users.scalars().all() + @router.get("/research-groups/", response_model=list[ResearchGroup]) + async def get_research_groups(session: SessionDep): + research_groups = session.exec(select(ResearchGroup)).all() + return research_groups + + @router.post("/research-groups/{user_id}", response_model=ResearchGroup) + async def create_research_group( + user_session: UserAsyncSessionDep, session: SessionDep, user_id: int + ): + user = await user_session.get(User, user_id) + if user is None: + raise HTTPException(404) + research_group = generate_research_group(session) + user.is_researcher = True + user.research_group_id = research_group.id + user_session.add(user) + await user_session.commit() + return research_group + + @router.delete("/research-groups/{research_group_id}") + async def delete_research_group(session: SessionDep, research_group_id: int): + research_group = get(session, ResearchGroup, research_group_id) + session.delete(research_group) + session.commit() + return {"ok": True} + return router diff --git a/mondey_backend/src/mondey_backend/routers/milestones.py b/mondey_backend/src/mondey_backend/routers/milestones.py index 68708d9e..ca92ab10 100644 --- a/mondey_backend/src/mondey_backend/routers/milestones.py +++ b/mondey_backend/src/mondey_backend/routers/milestones.py @@ -16,8 +16,8 @@ from ..models.milestones import SubmittedMilestoneImage from .utils import add from .utils import get -from .utils import get_child_age_in_months from .utils import get_db_child +from .utils import get_or_create_current_milestone_answer_session from .utils import submitted_milestone_image_path from .utils import write_image_file @@ -52,25 +52,18 @@ def get_milestone_groups( current_active_user: CurrentActiveUserDep, child_id: int, ): - delta_months = 6 child = get_db_child(session, current_active_user, child_id) + milestone_answer_session = get_or_create_current_milestone_answer_session( + session, current_active_user, child + ) + milestone_ids = list(milestone_answer_session.answers.keys()) - child_age_months = get_child_age_in_months(child) milestone_groups = session.exec( select(MilestoneGroup) .order_by(col(MilestoneGroup.order)) .options( lazyload( - MilestoneGroup.milestones.and_( - ( - child_age_months - >= col(Milestone.expected_age_months) - delta_months - ) - & ( - child_age_months - <= col(Milestone.expected_age_months) + delta_months - ) - ) + MilestoneGroup.milestones.and_(col(Milestone.id).in_(milestone_ids)) ) ) ).all() diff --git a/mondey_backend/src/mondey_backend/routers/scores.py b/mondey_backend/src/mondey_backend/routers/scores.py index c1dd7399..7d620130 100644 --- a/mondey_backend/src/mondey_backend/routers/scores.py +++ b/mondey_backend/src/mondey_backend/routers/scores.py @@ -1,32 +1,30 @@ from __future__ import annotations +from datetime import datetime +from datetime import timedelta from enum import Enum +from typing import cast import numpy as np -from sqlmodel import col -from sqlmodel import select -from ..dependencies import CurrentActiveUserDep from ..dependencies import SessionDep from ..models.children import Child from ..models.milestones import MilestoneAgeScore -from ..models.milestones import MilestoneAgeScores -from ..models.milestones import MilestoneAnswer +from ..models.milestones import MilestoneAgeScoreCollection from ..models.milestones import MilestoneAnswerSession -from ..models.milestones import MilestoneGroupStatistics -from .utils import _session_has_expired -from .utils import calculate_milestone_statistics_by_age -from .utils import calculate_milestonegroup_statistics +from ..models.milestones import MilestoneGroupAgeScore +from ..models.milestones import MilestoneGroupAgeScoreCollection +from .statistics import calculate_milestone_statistics_by_age +from .statistics import calculate_milestonegroup_statistics_by_age from .utils import get_child_age_in_months -from .utils import get_milestonegroups_for_answersession class TrafficLight(Enum): """ Enum for the trafficlight feedback. - Includes -1 for red, 0 for yellow, and 1 for green. - Invalid is -2 and is included for edge cases. + Includes -2 for red, -1 for overall yellow but with red minimum score (yellowWithCaveat), 0 for yellow, and 1 for green with yellow minimum score (greenWithCaveat). 2 is green overall. + Invalid is -3 and is included for edge cases like no data. """ invalid = -3 @@ -38,7 +36,7 @@ class TrafficLight(Enum): def compute_feedback_simple( - stat: MilestoneAgeScore | MilestoneGroupStatistics, + stat: MilestoneAgeScore | MilestoneGroupAgeScore, score: float, min_score: float | None = None, ) -> int: @@ -47,34 +45,37 @@ def compute_feedback_simple( want to change the feedback logic. Parameters ---------- - stat : MilestoneAgeScore + stat : MilestoneAgeScore | MilestoneGroupAgeScore Struct containing the average and standard deviation of the scores for a single milestone value : float Returns ------- int - -1 if score <= avg - 2 * stddev (trafficlight: red) + -2 if score <= avg - 2 * stddev (trafficlight: red) + -1 if 2*stddev < score <= avg - stddev and min < avg - 2*stddev (trafficlight: yellowWithCaveat) 0 if avg - 2 * stddev < score <= avg - stddev (trafficlight: yellow) - 1 if score > avg - stddev (trafficlight: green) + 1 if score > avg - stddev and avg - 2*stddev < score < avg - stddev (trafficlight: greenWithCaveat) + 2 if score > avg - stddev (trafficlight: green) """ def leq(val: float, lim: float) -> bool: - return val < lim or np.isclose(val, lim) + return val < lim or bool(np.isclose(val, lim)) + + if stat.avg_score < 1e-2 and stat.stddev_score < 1e-2: + # statistics has no data + return TrafficLight.invalid.value if stat.stddev_score < 1e-2: - # README: This happens when all the scores are the same, so any - # deviation towards lower values can be interpreted as - # underperformance. - # This logic relies on the score being integers, such that when the - # stddev is 0, the avg is an integer - # TODO: Check again what client wants to happen in such cases? + # statistics data is degenerate and has no variance <- few datapoints + # in this case, any score below the average is considered underperforming: + # one step below -> yellow, two steps below -> red lim_lower = stat.avg_score - 2 lim_upper = stat.avg_score - 1 else: lim_lower = stat.avg_score - 2 * stat.stddev_score lim_upper = stat.avg_score - stat.stddev_score - + print("eval: ", lim_lower, lim_upper, stat.avg_score, score) if leq(score, lim_lower): return TrafficLight.red.value elif score > lim_lower and leq(score, lim_upper): @@ -87,170 +88,145 @@ def leq(val: float, lim: float) -> bool: return TrafficLight.green.value -def compute_detailed_feedback_for_answers( - session: SessionDep, - answers: list[MilestoneAnswer], - statistics: dict[int, MilestoneAgeScores], - age: int, +def compute_milestonegroup_feedback_summary( + session: SessionDep, child_id: int, answersession_id: int ) -> dict[int, int]: - milestonegroup_result: dict[int, int] = {} # type: ignore - for answer in answers: - if statistics.get(answer.milestone_id) is None: # type: ignore - stat = calculate_milestone_statistics_by_age( - session, - answer.milestone_id, # type: ignore - ) # type: ignore - - statistics[answer.milestone_id] = stat # type: ignore - feedback = compute_feedback_simple( - statistics[answer.milestone_id].scores[age], # type: ignore - answer.answer, # type: ignore - ) # type: ignore - milestonegroup_result[answer.milestone_id] = feedback # type: ignore - return milestonegroup_result - - -def compute_detailed_milestonegroup_feedback_for_answersession( - session: SessionDep, - answersession: MilestoneAnswerSession, - child: Child, -) -> dict[int, dict[int, int]]: - age = get_child_age_in_months(child, answersession.created_at) - milestonegroups = get_milestonegroups_for_answersession(session, answersession) + """ + Compute the summary milestonegroup feedback for a single milestonegroup. This is done + by first calculating the mean score over all milestones that belong to the milestonegroup that + are relevant for the child when the given answersession was created. The mean is then + compared against the mean and standard deviation over the known population of children for the child's age. + When the statistics is outdated (older than a week currently) or there is none, it is recomputed and updated in the database. + See `compute_feedback_simple` for the feedback logic. - filtered_answers = { - m.id: [ - answersession.answers[ms.id] - for ms in m.milestones - if ms.id in answersession.answers and ms.id is not None - ] - for mid, m in milestonegroups.items() - } - - result: dict[int, dict[int, int]] = {} - statistics: dict[int, MilestoneAgeScores] = {} - for milestonegroup_id, answers in filtered_answers.items(): - milestonegroup_result = compute_detailed_feedback_for_answers( - session, answers, statistics, age - ) - result[milestonegroup_id] = milestonegroup_result # type: ignore - return result + Parameters + ---------- + session : SessionDep + database session + child_id : int + child to compute feedback for. Needed for age computation + answersession_id : int + answersession to compute feedback for. This contains the answers on which basis the feedback is computed + Returns + ------- + dict[int, int] + Dictionary of milestonegroup_id -> feedback + """ + answersession = session.get(MilestoneAnswerSession, answersession_id) -def compute_summary_milestonegroup_feedback_for_answersession( - session: SessionDep, - answersession: MilestoneAnswerSession, - child: Child, - age_limit_low=6, - age_limit_high=6, -) -> dict[int, int]: + if answersession is None: + raise ValueError("No answersession with id: ", answersession_id) + + # get child age + child = session.get(Child, child_id) + if child is None: + raise ValueError("No child with id: ", child_id) age = get_child_age_in_months(child, answersession.created_at) - # TODO: double check if this does the right thing + # extract milestonegroups + groups = set(answer.milestone_group_id for answer in answersession.answers.values()) + today = datetime.now() + + # for each milestonegroup, get the statistics, compute the current mean, and compute the feedback + # if the statistics is older than a week, we update it with the current data + feedback: dict[int, int] = {} + for group in groups: + stats = session.get(MilestoneGroupAgeScoreCollection, group) + + if stats is None or stats.created_at < today - timedelta(days=7): + new_stats = calculate_milestonegroup_statistics_by_age(session, group) - milestonegroups = get_milestonegroups_for_answersession(session, answersession) + if new_stats is None: + raise ValueError("No statistics for milestone group: ", group) - filtered_answers = { - milestonegroup.id: [ - answersession.answers[ms.id] - for ms in milestonegroup.milestones - if ms.id in answersession.answers and ms.id is not None + # update stuff in database + for new_score in new_stats.scores: + session.merge(new_score) + + session.merge(new_stats) + session.commit() + stats = new_stats + + # extract the answers for the current milestone group + group_answers = [ + answer.answer + 1 + for answer in answersession.answers.values() + if answer.milestone_group_id == group ] - for mid, milestonegroup in milestonegroups.items() - } - - milestone_group_results: dict[int, int] = {} - for milestonegroup_id, answers in filtered_answers.items(): - mg_stat = calculate_milestonegroup_statistics( - session, - milestonegroup_id, # type: ignore - age, - age_lower=age - age_limit_low, - age_upper=age + age_limit_high, + + # use the statistics recorded for a certain age as the basis for the feedback computation + feedback[group] = compute_feedback_simple( + stats.scores[age], float(np.mean(group_answers)), min(group_answers) ) - mg_stat.session_id = answersession.id # type: ignore - mg_stat.child_id = child.id # type: ignore - - mean_for_mg = np.nan_to_num(np.mean([a.answer for a in answers])) - min_for_mg = np.nan_to_num(np.min([a.answer for a in answers])) - - result = compute_feedback_simple(mg_stat, mean_for_mg, min_for_mg) - milestone_group_results[milestonegroup_id] = result # type: ignore - return milestone_group_results - - -def compute_summary_milestonegroup_feedback_for_all_sessions( - session: SessionDep, - child: Child, - age_limit_low=6, - age_limit_high=6, -) -> dict[str, dict[int, int]]: - results: dict[str, dict[int, int]] = {} - - # get all answer sessions and filter for completed ones - answersessions = [ - a - for a in session.exec( - select(MilestoneAnswerSession).where( - col(MilestoneAnswerSession.child_id) == child.id - ) - ).all() - if _session_has_expired(a) - ] - - if answersessions == []: - return results - else: - for answersession in answersessions: - milestone_group_results = ( - compute_summary_milestonegroup_feedback_for_answersession( - session, - answersession, - child, - age_limit_low=age_limit_low, - age_limit_high=age_limit_high, - ) - ) - - datestring = answersession.created_at.strftime("%d-%m-%Y") - results[datestring] = milestone_group_results - - return results - - -def compute_detailed_milestonegroup_feedback_for_all_sessions( - session: SessionDep, - current_active_user: CurrentActiveUserDep, - child: Child, -) -> dict[str, dict[int, dict[int, int]]]: - results: dict[str, dict[int, dict[int, int]]] = {} - - user = current_active_user() - # get all answer sessions and filter for completed ones - answersessions = [ - a - for a in session.exec( - select(MilestoneAnswerSession).where( - col(MilestoneAnswerSession.child_id) == child.id - and col(MilestoneAnswerSession.user_id) == user.id - ) - ).all() - if _session_has_expired(a) - ] - - if answersessions == []: - return results - else: - for answersession in answersessions: - milestone_group_results = ( - compute_detailed_milestonegroup_feedback_for_answersession( - session, - answersession, - child, + return feedback + + +def compute_milestonegroup_feedback_detailed( + session: SessionDep, child_id: int, answersession_id: int +) -> dict[int, dict[int, int]]: + """ + Compute the per-milestone (detailed) feedback for all answers in a given answersession. + This is done by comparing the given answer per milestone against the mean and standard deviation of the known population of children for the child's age. If this statistics is outdated (older than a week currently) or is + missing, it is recomputed and updated in the database. See `compute_feedback_simple` for the feedback logic. + Return a dictionary mapping milestonegroup -> [milestone -> feedback]. + Parameters + ---------- + session : SessionDep + database session + child_id : int + child to compute feedback for. Needed for age computation + answersession_id : int + answersession to compute feedback for. This contains the answers on which basis the feedback is computed + + Returns + ------- + dict[int, dict[int, int]] + Dictionary of milestonegroup_id -> [milestone_id -> feedback] + """ + answersession = session.get(MilestoneAnswerSession, answersession_id) + + if answersession is None: + raise ValueError("No answersession with id: ", answersession_id) + + # get child age + child = session.get(Child, child_id) + + if child is None: + raise ValueError("No child with id: ", child_id) + + age = get_child_age_in_months(child, answersession.created_at) + today = datetime.today() + + # for each milestonegroup, get the statistics, compute the current mean, and compute the feedback + feedback: dict[int, dict[int, int]] = {} + for milestone_id, answer in answersession.answers.items(): + # try to get statistics for the current milestone and update it if it's not there + # or is too old + stats = session.get(MilestoneAgeScoreCollection, milestone_id) + + if stats is None or stats.created_at < today - timedelta(days=7): + new_stats = calculate_milestone_statistics_by_age(session, milestone_id) + + if new_stats is None: + raise ValueError( + "No new statistics could be calculated for milestone: ", + milestone_id, ) - ) - datestring = answersession.created_at.strftime("%d-%m-%Y") - results[datestring] = milestone_group_results + # update stuff in database + for new_score in new_stats.scores: + session.merge(new_score) + + session.merge(new_stats) + session.commit() + stats = new_stats + + if answer.milestone_group_id not in feedback: + feedback[answer.milestone_group_id] = {} + + feedback[answer.milestone_group_id][cast(int, answer.milestone_id)] = ( + compute_feedback_simple(stats.scores[age], answer.answer + 1) + ) - return results + return feedback diff --git a/mondey_backend/src/mondey_backend/routers/statistics.py b/mondey_backend/src/mondey_backend/routers/statistics.py new file mode 100644 index 00000000..b5a5501d --- /dev/null +++ b/mondey_backend/src/mondey_backend/routers/statistics.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import datetime +from collections.abc import Sequence + +import numpy as np +from sqlalchemy import and_ +from sqlmodel import col +from sqlmodel import select + +from ..dependencies import SessionDep +from ..models.milestones import MilestoneAgeScore +from ..models.milestones import MilestoneAgeScoreCollection +from ..models.milestones import MilestoneAnswer +from ..models.milestones import MilestoneAnswerSession +from ..models.milestones import MilestoneGroupAgeScore +from ..models.milestones import MilestoneGroupAgeScoreCollection +from .utils import _get_answer_session_child_ages_in_months +from .utils import _get_expected_age_from_scores + + +# see: https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance +# reason for not using existing package: bessel correction usually not respected +# we are using Welford's method here. This necessitates recording the count. +def _add_sample( + count: int, + mean: float | int, + m2: float | int, + new_value: float | int, +) -> tuple[int, float | int, float | int]: + """ + Add a sample to the the current statistics. This function uses an online algorithm to compute the mean (directly) and an intermediate for the variance. This uses Welford's method with a slight + modification to avoid numerical instability. See https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance + for details. + + Parameters + ---------- + count : int + number of samples added so far. + mean : float | int + current mean of the samples. + m2 : float | int + intermediate value for the variance computation. + new_value : float | int + new sample to be added to the statistics. + + Returns + ------- + tuple[float | int, float | int, float | int] + updated count, mean, and m2 values. + """ + count += 1 + delta = new_value - mean + mean += delta / count + delta2 = new_value - mean + m2 += delta * delta2 + return count, mean, m2 + + +def _finalize_statistics( + count: int | np.ndarray, + mean: float | int | np.ndarray, + m2: float | int | np.ndarray, +) -> tuple[float | int | np.ndarray, float | np.ndarray, float | np.ndarray]: + """ + Compute the mean and standard deviation from the intermediate values. This function is used to finalize the statistics after a batch of new samples have been added. If arrays are supplied, they all need to have the + same shape. Values for the standard deviation for which the count is less than 2 are set to zero. + + Parameters + ---------- + count : int | np.ndarray + Current counts of samples. If ndarray, it contains the number of samples for each entry. + mean : float | int | np.ndarray + Current mean value of the samples. If ndarray, it contains the mean for each entry. + m2 : float | int | np.ndarray + Current intermediate value for variance computation. If ndarray, it contains the intermediate value for each entry. + + Returns + ------- + tuple[float | int | np.ndarray, float | np.ndarray, float | np.ndarray] + updated count, mean, and standard deviation values. + + Raises + ------ + ValueError + If arguments of incompatible types are given + + ValueError + If arrays have different shapes. + """ + if all(isinstance(x, float | int) for x in [count, mean, m2]): + if count < 2: + return count, mean, 0.0 + else: + var = m2 / (count - 1) + return count, mean, np.sqrt(var) + elif all(isinstance(x, np.ndarray) for x in [count, mean, m2]): + if not all(x.shape == count.shape for x in [mean, m2]): # type: ignore + raise ValueError( + "Given arrays for statistics computation must have the same shape." + ) + + with np.errstate(invalid="ignore"): + valid_counts = count >= 2 + variance = m2 + variance[valid_counts] /= count[valid_counts] - 1 # type: ignore + variance[np.invert(valid_counts)] = 0.0 # type: ignore + return ( + count, + np.nan_to_num(mean), + np.nan_to_num(np.sqrt(variance)), + ) # get stddev + else: + raise ValueError( + "Given values for statistics computation must be of type int|float|np.ndarray" + ) + + +def _get_statistics_by_age( + answers: Sequence[MilestoneAnswer], + child_ages: dict[int, int], + count: np.ndarray | None = None, + avg: np.ndarray | None = None, + stddev: np.ndarray | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Calculate mean and variance for a set of answers by age in months. Makes use of an online + algorithm for variance and mean calculation that updates preexisting statistics with the + values provided in the answers. + + Parameters + ---------- + answers : Sequence[MilestoneAnswer] + Answers to include in the statistics. + child_ages : dict[int, int] + Dictionary of answer_session_id -> age in months. + count : np.ndarray | None, optional + Number of elements from which the current statistics is built, by default None. + Will be initialized as a zero array if None. + avg : np.ndarray | None, optional + Current mean values per age, by default None. + Will be initialized as a zero array if None. + stddev : np.ndarray | None, optional + Current standard deviation values per age, by default None. + Will be initialized as a zero array if None. + Returns + ------- + tuple[np.ndarray, np.ndarray, np.ndarray] + Updated count, avg and stddev arrays. + """ + if count is None or avg is None or stddev is None: + max_age_months = 72 + count = np.zeros(max_age_months + 1, dtype=np.int32) + avg = np.zeros(max_age_months + 1, dtype=np.float64) + stddev = np.zeros(max_age_months + 1, dtype=np.float64) + + if child_ages == {}: + return count, avg, stddev + + # online algorithm computes variance, compute m2 from stddev + # we can ignore count-1 <= 0 because stddev is zero in this case + m2 = stddev**2 * (count - 1) + + for answer in answers: + age = child_ages[answer.answer_session_id] # type: ignore + new_count, new_avg, new_m2 = _add_sample( + count[age], avg[age], m2[age], answer.answer + 1 + ) + count[age] = new_count + avg[age] = new_avg + m2[age] = new_m2 + + count, avg, stddev = _finalize_statistics(count, avg, m2) # type: ignore + + return count, avg, stddev + + +def calculate_milestone_statistics_by_age( + session: SessionDep, + milestone_id: int, +) -> MilestoneAgeScoreCollection | None: + """ + Calculate the mean, variance of a milestone per age in months. + Takes into account only answers from expired sessions. If no statistics exist yet, all answers from expired sessions are considered, else only the ones newer than the last statistics are considered. + + Parameters + ---------- + session : SessionDep + database session + milestone_id : int + id of the milestone to calculate the statistics for + Returns + ------- + MilestoneAgeScoreCollection | None + MilestoneAgeScoreCollection object which contains a list of MilestoneAgeScore objects, + one for each month, or None if there are no answers for the milestoneg and no previous statistics. + """ + # TODO: when the answersession eventually has an expired flag, this can go again. + session_expired_days: int = 7 + + # get the newest statistics for the milestone + last_statistics = session.get(MilestoneAgeScoreCollection, milestone_id) + + # initialize avg and stddev scores with the last known statistics or to None if no statistics are available + child_ages = _get_answer_session_child_ages_in_months(session) + expiration_date = datetime.datetime.now() - datetime.timedelta( + days=session_expired_days + ) + + count = None + avg_scores = None + stddev_scores = None + + if last_statistics is None: + # no statistics exists yet -> all answers from expired sessions are relevant + + answers_query = ( + select(MilestoneAnswer) + .join( + MilestoneAnswerSession, + col(MilestoneAnswer.answer_session_id) == MilestoneAnswerSession.id, + ) + .where(MilestoneAnswer.milestone_id == milestone_id) + .where(MilestoneAnswerSession.created_at < expiration_date) + ) + else: + # initialize avg and stddev scores with the last known statistics + last_scores = last_statistics.scores + count = np.array([score.count for score in last_scores]) + avg_scores = np.array([score.avg_score for score in last_scores]) + stddev_scores = np.array([score.stddev_score for score in last_scores]) + + # we calculate the statistics with an online algorithm, so we only consider new data + # that has not been included in the last statistics but which stems from sessions that are expired + answers_query = ( + select(MilestoneAnswer) + .join( + MilestoneAnswerSession, + col(MilestoneAnswer.answer_session_id) == MilestoneAnswerSession.id, + ) + .where(MilestoneAnswer.milestone_id == milestone_id) + .where( + and_( + col(MilestoneAnswerSession.created_at) > last_statistics.created_at, + col(MilestoneAnswerSession.created_at) <= expiration_date, + ) # expired session only which are not in the last statistics + ) + ) + + answers = session.exec(answers_query).all() + + if len(answers) == 0: + # return last statistics if no new answers are available, because that is the best we can do then. + return last_statistics + else: + count, avg_scores, stddev_scores = _get_statistics_by_age( + answers, child_ages, count=count, avg=avg_scores, stddev=stddev_scores + ) + + expected_age = _get_expected_age_from_scores(avg_scores) + + # overwrite last_statistics with updated stuff --> set primary keys explicitly + return MilestoneAgeScoreCollection( + milestone_id=milestone_id, + expected_age=expected_age, + created_at=datetime.datetime.now(), + scores=[ + MilestoneAgeScore( + age=age, + milestone_id=milestone_id, + count=int( + count[age] + ), # need a conversion to avoid numpy.int32 being stored as byte object + avg_score=avg_scores[age], + stddev_score=stddev_scores[age], + expected_score=4 if age >= expected_age else 1, + ) + for age in range(0, len(avg_scores)) + ], + ) + + +def calculate_milestonegroup_statistics_by_age( + session: SessionDep, + milestonegroup_id: int, +) -> MilestoneGroupAgeScoreCollection | None: + """ + Calculate the mean, variance of a milestonegroup per age in months. + Takes into account only answers from expired sessions. If no statistics exist yet, all answers from expired sessions are considered, else only the ones newer than the last statistics are considered. + + Parameters + ---------- + session : SessionDep + database session + milestonegroup_id : int + id of the milestonegroup to calculate the statistics for + + Returns + ------- + MilestoneGroupAgeScoreCollection | None + MilestoneGroupAgeScoreCollection object which contains a list of MilestoneGroupAgeScore objects, + one for each month, or None if there are no answers for the milestonegroup and no previous statistics. + """ + + # TODO: when the answersession eventually has an 'expired' flag, this can go again. + session_expired_days: int = 7 + + # get the newest statistics for the milestonegroup + last_statistics = session.get(MilestoneGroupAgeScoreCollection, milestonegroup_id) + + child_ages = _get_answer_session_child_ages_in_months(session) + expiration_date = datetime.datetime.now() - datetime.timedelta( + days=session_expired_days + ) + + count = None + avg_scores = None + stddev_scores = None + # we have 2 kinds of querys that need to be executed depending on the existence of a statistics object + if last_statistics is None: + # no statistics exists yet -> all answers from expired sessions are relevant + answer_query = ( + select(MilestoneAnswer) + .join( + MilestoneAnswerSession, + col(MilestoneAnswer.answer_session_id) == MilestoneAnswerSession.id, + ) + .where(MilestoneAnswer.milestone_group_id == milestonegroup_id) + .where( + MilestoneAnswerSession.created_at + < expiration_date # expired session only + ) + ) + else: + # initialize avg and stddev scores with the last known statistics + count = np.array( + [score.count for score in last_statistics.scores], dtype=np.int32 + ) + avg_scores = np.array( + [score.avg_score for score in last_statistics.scores], dtype=np.float64 + ) + stddev_scores = np.array( + [score.stddev_score for score in last_statistics.scores] + ) + # we calculate the statistics with an online algorithm, so we only consider new data + # that has not been included in the last statistics but which stems from sessions that are expired + # README: same reason for type: ignore as in the function above + answer_query = ( + select(MilestoneAnswer) + .join( + MilestoneAnswerSession, + MilestoneAnswer.answer_session_id == MilestoneAnswerSession.id, # type: ignore + ) + .where(MilestoneAnswer.milestone_group_id == milestonegroup_id) + .where( + and_( + MilestoneAnswerSession.created_at > last_statistics.created_at, # type: ignore + MilestoneAnswerSession.created_at <= expiration_date, # type: ignore + ) + ) # expired session only which are not in the last statistics + ) + + answers = session.exec(answer_query).all() + + if len(answers) == 0: + # return last statistics if no new answers are available, because that is the best we can do then. + + return last_statistics + else: + count, avg, stddev = _get_statistics_by_age( + answers, child_ages, count=count, avg=avg_scores, stddev=stddev_scores + ) + + return MilestoneGroupAgeScoreCollection( + milestone_group_id=milestonegroup_id, + scores=[ + MilestoneGroupAgeScore( + milestone_group_id=milestonegroup_id, + age=age, + count=int( + count[age] + ), # need a conversion to avoid numpy.int32 being stored as byte object + avg_score=avg[age], + stddev_score=stddev[age], + ) + for age in range(0, len(avg)) + ], + created_at=datetime.datetime.now(), + ) diff --git a/mondey_backend/src/mondey_backend/routers/users.py b/mondey_backend/src/mondey_backend/routers/users.py index 0edc3490..4b3e7a45 100644 --- a/mondey_backend/src/mondey_backend/routers/users.py +++ b/mondey_backend/src/mondey_backend/routers/users.py @@ -12,11 +12,9 @@ from ..models.children import Child from ..models.children import ChildCreate from ..models.children import ChildPublic -from ..models.milestones import MilestoneAnswer from ..models.milestones import MilestoneAnswerPublic from ..models.milestones import MilestoneAnswerSession from ..models.milestones import MilestoneAnswerSessionPublic -from ..models.milestones import MilestoneGroup from ..models.milestones import MilestoneGroupPublic from ..models.questions import ChildAnswer from ..models.questions import ChildAnswerPublic @@ -25,14 +23,12 @@ from ..models.users import UserRead from ..models.users import UserUpdate from ..users import fastapi_users -from .scores import compute_detailed_feedback_for_answers -from .scores import compute_detailed_milestonegroup_feedback_for_answersession -from .scores import compute_summary_milestonegroup_feedback_for_answersession +from .scores import compute_milestonegroup_feedback_detailed +from .scores import compute_milestonegroup_feedback_summary from .utils import _session_has_expired from .utils import add from .utils import child_image_path from .utils import get -from .utils import get_child_age_in_months from .utils import get_db_child from .utils import get_milestonegroups_for_answersession from .utils import get_or_create_current_milestone_answer_session @@ -132,8 +128,9 @@ async def delete_child_image( def get_current_milestone_answer_session( session: SessionDep, current_active_user: CurrentActiveUserDep, child_id: int ): + child = get_db_child(session, current_active_user, child_id) milestone_answer_session = get_or_create_current_milestone_answer_session( - session, current_active_user, child_id + session, current_active_user, child ) return milestone_answer_session @@ -154,15 +151,9 @@ def update_milestone_answer( raise HTTPException(401) milestone_answer = milestone_answer_session.answers.get(answer.milestone_id) if milestone_answer is None: - milestone_answer = MilestoneAnswer( - answer_session_id=milestone_answer_session.id, - milestone_id=answer.milestone_id, - answer=answer.answer, - ) - add(session, milestone_answer) - else: - milestone_answer.answer = answer.answer - session.commit() + raise HTTPException(401) + milestone_answer.answer = answer.answer + session.commit() return milestone_answer # Endpoints for answers to user question @@ -270,48 +261,22 @@ def get_milestonegroups_for_session( answersession = get(session, MilestoneAnswerSession, answersession_id) return get_milestonegroups_for_answersession(session, answersession) - @router.get( - "/feedback/answersession={answersession_id}/milestonegroup={milestonegroup_id}/detailed", - response_model=dict[int, int], - ) - def get_detailed_feedback_for_milestonegroup( - session: SessionDep, - current_active_user: CurrentActiveUserDep, - answersession_id: int, - milestonegroup_id: int, - ) -> dict[int, int]: - answersession = get(session, MilestoneAnswerSession, answersession_id) - m = get(session, MilestoneGroup, milestonegroup_id) - answers = [ - answersession.answers[ms.id] - for ms in m.milestones - if ms.id in answersession.answers and ms.id is not None - ] - child = get_db_child(session, current_active_user, answersession.child_id) - age = get_child_age_in_months(child, answersession.created_at) - statistics = {} # type: ignore - feedback = compute_detailed_feedback_for_answers( - session, - answers, - statistics, - age, - ) - return feedback - @router.get( "/feedback/answersession={answersession_id}/summary", response_model=dict[int, int], ) def get_summary_feedback_for_answersession( session: SessionDep, - current_active_user: CurrentActiveUserDep, answersession_id: int, ) -> dict[int, int]: answersession = get(session, MilestoneAnswerSession, answersession_id) - child = get_db_child(session, current_active_user, answersession.child_id) - return compute_summary_milestonegroup_feedback_for_answersession( - session, answersession, child, age_limit_low=6, age_limit_high=6 + if answersession is None: + raise HTTPException(404, detail="Answer session not found") + child_id = answersession.child_id + feedback = compute_milestonegroup_feedback_summary( + session, child_id, answersession_id ) + return feedback @router.get( "/feedback/answersession={answersession_id}/detailed", @@ -319,13 +284,15 @@ def get_summary_feedback_for_answersession( ) def get_detailed_feedback_for_answersession( session: SessionDep, - current_active_user: CurrentActiveUserDep, answersession_id: int, ) -> dict[int, dict[int, int]]: - answersession = get(session, MilestoneAnswerSession, answersession_id) - child = get_db_child(session, current_active_user, answersession.child_id) - return compute_detailed_milestonegroup_feedback_for_answersession( - session, answersession, child + answersession = session.get(MilestoneAnswerSession, answersession_id) + if answersession is None: + raise HTTPException(404, detail="Answer session not found") + child_id = answersession.child_id + feedback = compute_milestonegroup_feedback_detailed( + session, child_id, answersession_id ) + return feedback return router diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index ed760205..7605a424 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -4,7 +4,6 @@ import logging import pathlib from collections.abc import Iterable -from collections.abc import Sequence from typing import TypeVar import numpy as np @@ -22,13 +21,10 @@ from ..models.children import Child from ..models.milestones import Milestone from ..models.milestones import MilestoneAdmin -from ..models.milestones import MilestoneAgeScore -from ..models.milestones import MilestoneAgeScores from ..models.milestones import MilestoneAnswer from ..models.milestones import MilestoneAnswerSession from ..models.milestones import MilestoneGroup from ..models.milestones import MilestoneGroupAdmin -from ..models.milestones import MilestoneGroupStatistics from ..models.milestones import MilestoneGroupText from ..models.milestones import MilestoneText from ..models.questions import ChildQuestion @@ -140,29 +136,44 @@ def _session_has_expired(milestone_answer_session: MilestoneAnswerSession) -> bo def get_or_create_current_milestone_answer_session( - session: SessionDep, current_active_user: User, child_id: int + session: SessionDep, current_active_user: User, child: Child ) -> MilestoneAnswerSession: - get_db_child(session, current_active_user, child_id) - milestone_answer_session = session.exec( select(MilestoneAnswerSession) - .where( - (col(MilestoneAnswerSession.user_id) == current_active_user.id) - & (col(MilestoneAnswerSession.child_id) == child_id) - ) + .where(col(MilestoneAnswerSession.user_id) == current_active_user.id) + .where(col(MilestoneAnswerSession.child_id) == child.id) .order_by(col(MilestoneAnswerSession.created_at).desc()) ).first() - if milestone_answer_session is None or _session_has_expired( milestone_answer_session ): milestone_answer_session = MilestoneAnswerSession( - child_id=child_id, + child_id=child.id, user_id=current_active_user.id, created_at=datetime.datetime.now(), ) add(session, milestone_answer_session) - + delta_months = 6 + child_age_months = get_child_age_in_months(child) + milestones = session.exec( + select(Milestone) + .where( + child_age_months >= col(Milestone.expected_age_months) - delta_months + ) + .where( + child_age_months <= col(Milestone.expected_age_months) + delta_months + ) + ).all() + for milestone in milestones: + session.add( + MilestoneAnswer( + answer_session_id=milestone_answer_session.id, + milestone_id=milestone.id, + milestone_group_id=milestone.group_id, + answer=-1, + ) + ) + session.commit() return milestone_answer_session @@ -187,8 +198,6 @@ def get_db_child( def _get_answer_session_child_ages_in_months(session: SessionDep) -> dict[int, int]: answer_sessions = session.exec(select(MilestoneAnswerSession)).all() - print(answer_sessions) - return { answer_session.id: get_child_age_in_months( # type: ignore get(session, Child, answer_session.child_id), answer_session.created_at @@ -202,128 +211,6 @@ def _get_expected_age_from_scores(scores: np.ndarray) -> int: return np.argmax(scores >= 3.0) -def _calculate_statistics_for( - data: Sequence[int | float], **statfuncs -) -> dict[str, float | np.ndarray | tuple]: - result = {} - for name, func in statfuncs.items(): - with np.errstate(invalid="ignore"): - stat = func(data) - result[name] = stat - return result - - -def _get_score_statistics_by_age( - answers: Sequence[MilestoneAnswer], child_ages: dict[int, int] -) -> tuple[np.ndarray, np.ndarray]: - max_age_months = 72 - avg_scores = np.zeros(max_age_months + 1) - stddev_scores = np.zeros(max_age_months + 1) - counts = np.zeros_like(avg_scores) - if child_ages == {}: - return avg_scores, stddev_scores - - # compute average - for answer in answers: - age = child_ages[answer.answer_session_id] # type: ignore - # convert 0-3 answer index to 1-4 score - avg_scores[age] += answer.answer + 1 - counts[age] += 1 - - with np.errstate(invalid="ignore"): - avg_scores /= counts - - # compute standard deviation - for answer in answers: - age = child_ages[answer.answer_session_id] # type: ignore - stddev_scores[age] += (answer.answer + 1 - avg_scores[age]) ** 2 - - with np.errstate(invalid="ignore"): - stddev_scores = np.sqrt(stddev_scores / np.max(counts - 1, 0)) - - # replace NaNs (due to zero counts) with zeros - avg = np.nan_to_num(avg_scores) - stddev = np.nan_to_num(stddev_scores) - - return avg, stddev - - -def calculate_milestone_statistics_by_age( - session: SessionDep, - milestone_id: int, - answers: Sequence[MilestoneAnswer] | None = None, -) -> MilestoneAgeScores: - child_ages = _get_answer_session_child_ages_in_months(session) - - if answers is None: - answers = session.exec( - select(MilestoneAnswer).where( - col(MilestoneAnswer.milestone_id) == milestone_id - ) - ).all() - avg, stddev = _get_score_statistics_by_age(answers, child_ages) - expected_age = _get_expected_age_from_scores(avg) - - return MilestoneAgeScores( - expected_age=expected_age, - scores=[ - MilestoneAgeScore( - milestone_id=milestone_id, - age_months=age, - avg_score=avg[age], - stddev_score=stddev[age], - expected_score=( - 4 if age >= expected_age else 1 - ), # FIXME: donĀ“t know what this is supposed to mean - ) - for age in range(0, len(avg)) - ], - ) - - -def calculate_milestonegroup_statistics( - session: SessionDep, - mid: int, - age: int, - age_lower: int, - age_upper: int, -) -> MilestoneGroupStatistics: - milestonegroup = get(session, MilestoneGroup, mid) - answers = [] - for milestone in milestonegroup.milestones: - # we want something that is relevant for the age of the child at hand. Hence we filter by age here. Is this what they want? - # FIXME: 11-25-2024: I think this is not what we want and it should be filtered by the age of the child at the time of the answer session? - # this however should already be handled by the answersession itself? - # dazed and confused.... - # At any rate the above comment is obsolete. - m_answers = [ - answer.answer - for answer in session.exec( - select(MilestoneAnswer) - .where(col(MilestoneAnswer.milestone_id) == milestone.id) - .where(age_lower <= milestone.expected_age_months <= age_upper) - ).all() - ] - answers.extend(m_answers) - - answers = np.array(answers) + 1 # convert 0-3 answer index to 1-4 score - - result = _calculate_statistics_for( - answers, - mean=np.mean, - std=lambda a: np.std(a, correction=1), - ) - - mg_score = MilestoneGroupStatistics( - age_months=age, - group_id=milestonegroup.id, - avg_score=np.nan_to_num(result["mean"]), - stddev_score=np.nan_to_num(result["std"]), - ) - - return mg_score - - def child_image_path(child_id: int | None) -> pathlib.Path: return pathlib.Path(f"{app_settings.PRIVATE_FILES_PATH}/children/{child_id}.webp") diff --git a/mondey_backend/src/mondey_backend/settings.py b/mondey_backend/src/mondey_backend/settings.py index af2a7acd..df99559d 100644 --- a/mondey_backend/src/mondey_backend/settings.py +++ b/mondey_backend/src/mondey_backend/settings.py @@ -14,6 +14,7 @@ class AppSettings(BaseSettings): PRIVATE_FILES_PATH: str = "private" ENABLE_CORS: bool = True HOST: str = "localhost" + SMTP_HOST: str = "email:587" PORT: int = 8000 RELOAD: bool = True LOG_LEVEL: str = "debug" diff --git a/mondey_backend/src/mondey_backend/users.py b/mondey_backend/src/mondey_backend/users.py index da5a0823..472f3f11 100644 --- a/mondey_backend/src/mondey_backend/users.py +++ b/mondey_backend/src/mondey_backend/users.py @@ -1,8 +1,8 @@ -# TODO: 17th Oct. 2024: remove the artificial verification setting again as soon as -# the email verification server has been implemented. See 'README' block @ line 33f - from __future__ import annotations +import logging +import smtplib +from email.message import EmailMessage from typing import Annotated from fastapi import Depends @@ -15,32 +15,47 @@ from fastapi_users.authentication.strategy.db import AccessTokenDatabase from fastapi_users.authentication.strategy.db import DatabaseStrategy from fastapi_users.db import SQLAlchemyUserDatabase +from sqlmodel import Session +from .databases.mondey import engine as mondey_engine from .databases.users import AccessToken from .databases.users import User from .databases.users import async_session_maker from .databases.users import get_access_token_db from .databases.users import get_user_db +from .models.research import ResearchGroup from .settings import app_settings +def send_email_validation_link(email: str, token: str) -> None: + msg = EmailMessage() + msg["From"] = "no-reply@mondey.lkeegan.dev" + msg["To"] = email + msg["Subject"] = "MONDEY-Konto aktivieren" + msg.set_content( + f"Bitte klicken Sie hier, um Ihr MONDEY-Konto zu aktivieren:\n\nhttps://mondey.lkeegan.dev/verify/{token}\n\n-----\n\nPlease click here to activate your MONDEY account:\n\nhttps://mondey.lkeegan.dev/verify/{token}" + ) + with smtplib.SMTP(app_settings.SMTP_HOST) as s: + s.send_message(msg) + + class UserManager(IntegerIDMixin, BaseUserManager[User, int]): reset_password_token_secret = app_settings.SECRET verification_token_secret = app_settings.SECRET async def on_after_register(self, user: User, request: Request | None = None): - # README: Sets the verified flag artificially to allow users to work without an - # actual verification process for now. this can go again as soon as we have an email server for verification. - async with async_session_maker() as session: - user_db = await session.get(User, user.id) - if user_db: - user_db.is_verified = True - await session.commit() - await session.refresh(user_db) - - print(f"User {user_db.id} has registered.") - print(f"User is verified? {user_db.is_verified}") - # end README + logging.info(f"User {user.email} registered.") + with Session(mondey_engine) as mondey_session: + if mondey_session.get(ResearchGroup, user.research_group_id) is None: + logging.warning( + f"Invalid research code {user.research_group_id} used by User {user.email} - ignoring." + ) + async with async_session_maker() as user_session: + user_db = await user_session.get(User, user.id) + if user_db is not None: + user_db.research_group_id = 0 + await user_session.commit() + await self.request_verify(user, request) async def on_after_forgot_password( self, user: User, token: str, request: Request | None = None @@ -50,7 +65,10 @@ async def on_after_forgot_password( async def on_after_request_verify( self, user: User, token: str, request: Request | None = None ): - print(f"Verification requested for user {user.id}. Verification token: {token}") + logging.info( + f"Verification requested for user {user.id}. Verification token: {token}" + ) + send_email_validation_link(user.email, token) async def get_user_manager( diff --git a/mondey_backend/tests/conftest.py b/mondey_backend/tests/conftest.py index e24aa4b5..fa0ae780 100644 --- a/mondey_backend/tests/conftest.py +++ b/mondey_backend/tests/conftest.py @@ -27,9 +27,13 @@ from mondey_backend.models.children import Child from mondey_backend.models.milestones import Language from mondey_backend.models.milestones import Milestone +from mondey_backend.models.milestones import MilestoneAgeScore +from mondey_backend.models.milestones import MilestoneAgeScoreCollection from mondey_backend.models.milestones import MilestoneAnswer from mondey_backend.models.milestones import MilestoneAnswerSession from mondey_backend.models.milestones import MilestoneGroup +from mondey_backend.models.milestones import MilestoneGroupAgeScore +from mondey_backend.models.milestones import MilestoneGroupAgeScoreCollection from mondey_backend.models.milestones import MilestoneGroupText from mondey_backend.models.milestones import MilestoneImage from mondey_backend.models.milestones import MilestoneText @@ -40,6 +44,7 @@ from mondey_backend.models.questions import UserAnswer from mondey_backend.models.questions import UserQuestion from mondey_backend.models.questions import UserQuestionText +from mondey_backend.models.research import ResearchGroup from mondey_backend.models.users import Base from mondey_backend.models.users import User from mondey_backend.models.users import UserRead @@ -81,12 +86,9 @@ def private_dir(tmp_path_factory: pytest.TempPathFactory): def children(): today = datetime.datetime.today() - # README: this is not entirel stable for all dates. For example, for - # 26th november = today, nine-months ago give 1st March, which then gives - # you 8 months instead of 9 if done as today - datetime.timedelta(days=9 * 30) - # Hence: use dateutil.relativedelta which takes care of the 31 vs 30 vs 28 days stuff - nine_months_ago = today - relativedelta(months=9) # type: ignore - twenty_months_ago = today - relativedelta(months=20) # type: ignore + nine_months_ago = today - relativedelta(months=9) + twenty_months_ago = today - relativedelta(months=20) + return [ # ~9month old child for user (id 3) { @@ -121,12 +123,15 @@ async def user_session( active_research_user: UserRead, active_user: UserRead, active_user2: UserRead, + monkeypatch: pytest.MonkeyPatch, ): # use a new in-memory SQLite user database for each test engine = create_async_engine("sqlite+aiosqlite://") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async_session_maker = async_sessionmaker(engine, expire_on_commit=False) + # we also need to monkey patch the async_session_maker which is directly used in the users module + monkeypatch.setattr("mondey_backend.users.async_session_maker", async_session_maker) async with async_session_maker() as session: for user_read in [ active_admin_user, @@ -138,11 +143,12 @@ async def user_session( for k, v in user_read.model_dump().items(): setattr(user, k, v) session.add(user) + await session.commit() yield session @pytest.fixture -def session(children: list[dict]): +def session(children: list[dict], monkeypatch: pytest.MonkeyPatch): # use a new in-memory SQLite database for each test engine = create_engine( "sqlite://", @@ -151,6 +157,8 @@ def session(children: list[dict]): echo=False, ) SQLModel.metadata.create_all(engine) + # we also need to monkey patch the mondey_engine which is directly used in the users module + monkeypatch.setattr("mondey_backend.users.mondey_engine", engine) # add some test data with Session(engine) as session: # add 3 languages @@ -186,6 +194,7 @@ def session(children: list[dict]): help=f"{lbl}_h", ) ) + # add a second milestone group with 2 milestones session.add(MilestoneGroup(order=1)) for lang_id in lang_ids: @@ -195,10 +204,13 @@ def session(children: list[dict]): group_id=2, lang_id=lang_id, title=f"{lbl}_t", desc=f"{lbl}_d" ) ) + for milestone_id in [4, 5]: session.add( Milestone( - order=milestone_id, group_id=2, expected_age_months=milestone_id * 6 + order=milestone_id, + group_id=2, + expected_age_months=milestone_id * 6, ) ) for lang_id in lang_ids: @@ -223,18 +235,7 @@ def session(children: list[dict]): for child, user_id in zip(children, [3, 3, 1], strict=False): session.add(Child.model_validate(child, update={"user_id": user_id})) today = datetime.datetime.today() - last_month = today - datetime.timedelta(days=30) - - for child in children: - print("child: ", child) - - print("today: ", today) - print("time delta: ", last_month) - print( - "session created at: ", - datetime.datetime(last_month.year, last_month.month, last_month.day), - ) - + last_month = today - relativedelta(months=1) # add an (expired) milestone answer session for child 1 / user (id 3) with 2 answers session.add( MilestoneAnswerSession( @@ -245,13 +246,29 @@ def session(children: list[dict]): ), ) ) - session.add(MilestoneAnswer(answer_session_id=1, milestone_id=1, answer=1)) - session.add(MilestoneAnswer(answer_session_id=1, milestone_id=2, answer=0)) + session.add( + MilestoneAnswer( + answer_session_id=1, milestone_id=1, milestone_group_id=1, answer=1 + ) + ) + session.add( + MilestoneAnswer( + answer_session_id=1, milestone_id=2, milestone_group_id=1, answer=0 + ) + ) # add another (current) milestone answer session for child 1 / user (id 3) with 2 answers to the same questions session.add(MilestoneAnswerSession(child_id=1, user_id=3, created_at=today)) # add two milestone answers - session.add(MilestoneAnswer(answer_session_id=2, milestone_id=1, answer=3)) - session.add(MilestoneAnswer(answer_session_id=2, milestone_id=2, answer=2)) + session.add( + MilestoneAnswer( + answer_session_id=2, milestone_id=1, milestone_group_id=1, answer=3 + ) + ) + session.add( + MilestoneAnswer( + answer_session_id=2, milestone_id=2, milestone_group_id=1, answer=2 + ) + ) # add an (expired) milestone answer session for child 3 / admin user (id 1) with 1 answer session.add( MilestoneAnswerSession( @@ -260,8 +277,13 @@ def session(children: list[dict]): created_at=datetime.datetime(today.year - 1, 1, 1), ) ) - session.add(MilestoneAnswer(answer_session_id=3, milestone_id=7, answer=2)) - + session.add( + MilestoneAnswer( + answer_session_id=3, milestone_id=7, milestone_group_id=2, answer=2 + ) + ) + # add a research group (that user with id 3 is part of, and researcher with id 2 has access to) + session.add(ResearchGroup(id="123451")) # add user questions for admin user_questions = [ UserQuestion( @@ -436,6 +458,145 @@ def session(children: list[dict]): yield session +@pytest.fixture +def statistics_session(session): + today = datetime.datetime.today() + last_month = today - relativedelta(months=1) + two_weeks_ago = today - relativedelta(weeks=2) + + # add another expired milestoneanswersession for milestones 1, 2 for child + session.add( + MilestoneAnswerSession( + child_id=1, + user_id=3, + created_at=datetime.datetime( + two_weeks_ago.year, two_weeks_ago.month, two_weeks_ago.day + ), + ) + ) + session.add( + MilestoneAnswer( + answer_session_id=4, milestone_id=1, milestone_group_id=1, answer=3 + ) + ) + session.add( + MilestoneAnswer( + answer_session_id=4, milestone_id=2, milestone_group_id=1, answer=2 + ) + ) + + # add another expired answersession for milestone 7 for child 3 that is a bit later + # than answersession 3 (the last one for the same child), but still expired + session.add( + MilestoneAnswerSession( + child_id=3, user_id=1, created_at=datetime.datetime(today.year - 1, 1, 10) + ) + ) + session.add( + MilestoneAnswer( + answer_session_id=5, milestone_id=7, milestone_group_id=2, answer=1 + ) + ) + + # add MilestoneAgeScoreCollections for milestone 1 and 2. Done such that + # answersession 4 added above did not yet factor into its calculation + # numbers for avg/stddev in the scores will be arbitrary + session.add( + MilestoneAgeScoreCollection( + milestone_id=1, + expected_age=8, + created_at=datetime.datetime( + last_month.year, + last_month.month, + last_month.day + 2, # between answersessions -> recompute + ), + ) + ) + + session.add( + MilestoneAgeScoreCollection( + milestone_id=2, + expected_age=8, + created_at=datetime.datetime( + last_month.year, + last_month.month, + last_month.day + 2, # between answersessions -> recompute + ), + ) + ) + + def sigma(age, lower, upper, value): + if age < lower or age >= upper: + return 0 + else: + return value + + # add scores for milestone 1 and 2 + for age in range(0, 73): + session.add( + MilestoneAgeScore( + age=age, + milestone_id=1, + count=12, + avg_score=0.0 + if age < 5 + else min( + 1 * age - 5, 3 + ), # linear increase from some age onward arbitrary numbers here + stddev_score=sigma( + age, 5, 8, 0.35 + ), # arbitrary numbers here. constant stddev for increasing avg else 0 + expected_score=3 if age >= 8 else 1, + ) + ) + session.add( + MilestoneAgeScore( + age=age, + milestone_id=2, + count=7, + avg_score=0.0 if age < 5 else min(0.5 * age - 2, 3), + stddev_score=sigma(age, 5, 10, 0.4), + expected_score=3 if age >= 10 else 1, + ) + ) + + # add milestonegroup age score collection for milestonegroup 1 + # which is a month old and hence is. repeats the logic used for the + # MilestoneAgeScores + session.add( + MilestoneGroupAgeScoreCollection( + milestone_group_id=1, + created_at=datetime.datetime( + last_month.year, + last_month.month, + last_month.day + 2, # between answersessions -> recompute + ), + ) + ) + + for age in range(0, 73): + session.add( + MilestoneGroupAgeScore( + age=age, + milestone_group_id=1, + count=4 + if age + in [ + 5, + 6, + 7, + 8, + ] + else 0, + avg_score=0.0 if age < 5 else min(0.24 * age, 3), + stddev_score=sigma(age, 5, 9, 0.21), + ) + ) + + session.commit() + yield session + + @pytest.fixture def user_questions(): return [ @@ -637,6 +798,8 @@ def active_admin_user(): is_active=True, is_superuser=True, is_researcher=False, + full_data_access=False, + research_group_id=0, is_verified=True, ) @@ -649,6 +812,8 @@ def active_research_user(): is_active=True, is_superuser=False, is_researcher=True, + full_data_access=False, + research_group_id=123451, is_verified=True, ) @@ -661,6 +826,8 @@ def active_user(): is_active=True, is_superuser=False, is_researcher=False, + full_data_access=False, + research_group_id=123451, is_verified=True, ) @@ -673,6 +840,8 @@ def active_user2(): is_active=True, is_superuser=False, is_researcher=False, + full_data_access=False, + research_group_id=0, is_verified=True, ) @@ -744,6 +913,20 @@ def admin_client( app.dependency_overrides.clear() +@pytest.fixture +def admin_client_stat( + app: FastAPI, + statistics_session: Session, + user_session: AsyncSession, + active_admin_user: UserRead, +): + app.dependency_overrides[current_active_user] = lambda: active_admin_user + app.dependency_overrides[current_active_superuser] = lambda: active_admin_user + client = TestClient(app) + yield client + app.dependency_overrides.clear() + + @pytest.fixture def image_file_jpg_1600_1200(tmp_path: pathlib.Path): jpg_path = tmp_path / "test.jpg" diff --git a/mondey_backend/tests/routers/admin_routers/test_admin_milestones.py b/mondey_backend/tests/routers/admin_routers/test_admin_milestones.py index 3d57faab..93907559 100644 --- a/mondey_backend/tests/routers/admin_routers/test_admin_milestones.py +++ b/mondey_backend/tests/routers/admin_routers/test_admin_milestones.py @@ -224,17 +224,27 @@ def test_post_milestone_image( assert len(admin_client.get("/milestones/5").json()["images"]) == 0 -def test_get_milestone_age_scores(admin_client: TestClient): - response = admin_client.get("/admin/milestone-age-scores/1") +def test_get_milestone_age_scores(admin_client_stat: TestClient): + response = admin_client_stat.get("/admin/milestone-age-scores/1") assert response.status_code == 200 - # child 1 scored - # - 2 @ ~8 months old - # - 4 @ ~9 months old - assert response.json()["expected_age"] == 9 - assert response.json()["scores"][7]["avg_score"] == pytest.approx(0.0) - assert response.json()["scores"][8]["avg_score"] == pytest.approx(2.0) - assert response.json()["scores"][9]["avg_score"] == pytest.approx(4.0) - assert response.json()["scores"][10]["avg_score"] == pytest.approx(0.0) + + assert response.json()["expected_age"] == 8 + + assert response.json()["scores"][7]["avg_score"] == pytest.approx(2.0) + assert response.json()["scores"][7]["stddev_score"] == pytest.approx(0.35) + assert response.json()["scores"][7]["count"] == 12 + + assert response.json()["scores"][8]["avg_score"] == pytest.approx(3.0) + assert response.json()["scores"][8]["stddev_score"] == pytest.approx(0.0) + assert response.json()["scores"][8]["count"] == 12 + + assert response.json()["scores"][9]["avg_score"] == pytest.approx(3.0) + assert response.json()["scores"][9]["stddev_score"] == pytest.approx(0.0) + assert response.json()["scores"][9]["count"] == 12 + + assert response.json()["scores"][10]["avg_score"] == pytest.approx(3.0) + assert response.json()["scores"][10]["stddev_score"] == pytest.approx(0.0) + assert response.json()["scores"][10]["count"] == 12 def test_get_submitted_milestone_images(admin_client: TestClient): diff --git a/mondey_backend/tests/routers/admin_routers/test_admin_users.py b/mondey_backend/tests/routers/admin_routers/test_admin_users.py index 55964dce..a0054cbc 100644 --- a/mondey_backend/tests/routers/admin_routers/test_admin_users.py +++ b/mondey_backend/tests/routers/admin_routers/test_admin_users.py @@ -1,3 +1,4 @@ +from checkdigit import verhoeff from fastapi.testclient import TestClient @@ -18,3 +19,29 @@ def test_users(admin_client: TestClient): assert users[3]["id"] == 4 assert not users[3]["is_researcher"] assert not users[3]["is_superuser"] + + +def test_get_research_groups(admin_client: TestClient): + response = admin_client.get("/admin/research-groups/") + assert response.status_code == 200 + research_groups = response.json() + assert research_groups == [{"id": 123451}] + + +def test_delete_research_group(admin_client: TestClient): + response = admin_client.delete("/admin/research-groups/123451") + assert response.status_code == 200 + assert admin_client.get("/admin/research-groups/").json() == [] + + +def test_create_research_group(admin_client: TestClient): + research_group = {"id": 123451} + assert admin_client.get("/admin/research-groups/").json() == [research_group] + response = admin_client.post("/admin/research-groups/2") + assert response.status_code == 200 + new_research_group = response.json() + assert verhoeff.validate(str(new_research_group["id"])) + assert admin_client.get("/admin/research-groups/").json() == [ + research_group, + new_research_group, + ] diff --git a/mondey_backend/tests/routers/test_auth.py b/mondey_backend/tests/routers/test_auth.py new file mode 100644 index 00000000..fedc1e50 --- /dev/null +++ b/mondey_backend/tests/routers/test_auth.py @@ -0,0 +1,75 @@ +import smtplib +from email.message import EmailMessage + +import pytest +from fastapi.testclient import TestClient + + +class SMTPMock: + last_message: EmailMessage | None = None + + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def send_message(self, msg: EmailMessage, **kwargs): + SMTPMock.last_message = msg + + +@pytest.fixture +def smtp_mock(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(smtplib, "SMTP", SMTPMock) + SMTPMock.last_message = None + return SMTPMock + + +def test_register_new_user(public_client: TestClient, smtp_mock: SMTPMock): + assert smtp_mock.last_message is None + email = "u1@asdgdasf.com" + response = public_client.post( + "/auth/register", json={"email": email, "password": "p1"} + ) + assert response.status_code == 201 + msg = smtp_mock.last_message + assert msg is not None + assert "aktivieren" in msg.get("Subject").lower() + assert msg.get("To") == email + assert "/verify/" in msg.get_content() + response = public_client.post("/auth/verify", json={"token": "invalid-token"}) + assert response.status_code == 400 + token = msg.get_content().split("\n\n")[1].rsplit("/")[-1] + response = public_client.post("/auth/verify", json={"token": token}) + assert response.status_code == 200 + + +def test_register_new_user_invalid_research_code_ignored( + admin_client: TestClient, smtp_mock: SMTPMock +): + email = "a@b.com" + response = admin_client.post( + "/auth/register", + json={"email": email, "password": "p1", "research_group_id": 703207}, + ) + assert response.status_code == 201 + new_user = admin_client.get("/admin/users/").json()[-1] + assert new_user["email"] == email + assert new_user["research_group_id"] == 0 + + +def test_register_new_user_valid_research_code( + admin_client: TestClient, smtp_mock: SMTPMock +): + email = "a@b.com" + response = admin_client.post( + "/auth/register", + json={"email": email, "password": "p1", "research_group_id": 123451}, + ) + assert response.status_code == 201 + new_user = admin_client.get("/admin/users/").json()[-1] + assert new_user["email"] == email + assert new_user["research_group_id"] == 123451 diff --git a/mondey_backend/tests/routers/test_milestones.py b/mondey_backend/tests/routers/test_milestones.py index f9834e4e..a8d7d737 100644 --- a/mondey_backend/tests/routers/test_milestones.py +++ b/mondey_backend/tests/routers/test_milestones.py @@ -51,6 +51,7 @@ def test_get_milestone_groups_child3( milestone_group1["milestones"] = [] # and first last milestone from group2 (24m): milestone_group2["milestones"] = [] + assert response.json() == [milestone_group2, milestone_group1] diff --git a/mondey_backend/tests/routers/test_users.py b/mondey_backend/tests/routers/test_users.py index 2ff75226..d16ba7ca 100644 --- a/mondey_backend/tests/routers/test_users.py +++ b/mondey_backend/tests/routers/test_users.py @@ -183,22 +183,22 @@ def test_get_milestone_answers_child1_current_answer_session(user_client: TestCl assert _is_approx_now(response.json()["created_at"]) -def test_update_milestone_answer_current_answer_session_no_answer_session( +def test_update_milestone_answer_no_current_answer_session( user_client: TestClient, ): - current_answer_session = user_client.get("/users/milestone-answers/1").json() - assert current_answer_session["child_id"] == 1 - assert "6" not in current_answer_session["answers"] - new_answer = {"milestone_id": 6, "answer": 2} + current_answer_session = user_client.get("/users/milestone-answers/2").json() + assert current_answer_session["child_id"] == 2 + + # child 2 is 20 months old, so milestones 4 + assert current_answer_session["answers"]["4"]["answer"] == -1 + new_answer = {"milestone_id": 4, "answer": 2} response = user_client.put( f"/users/milestone-answers/{current_answer_session['id']}", json=new_answer ) assert response.status_code == 200 assert response.json() == new_answer - assert ( - user_client.get("/users/milestone-answers/1").json()["answers"]["6"] - == new_answer - ) + new_answer_session = user_client.get("/users/milestone-answers/2").json() + assert new_answer_session["answers"]["4"] == new_answer def test_update_milestone_answer_update_existing_answer(user_client: TestClient): @@ -355,28 +355,20 @@ def test_update_current_child_answers_no_prexisting( def test_get_summary_feedback_for_session(user_client: TestClient): response = user_client.get("/users/feedback/answersession=1/summary") assert response.status_code == 200 - assert response.json() == {"1": 0} + assert response.json() == {"1": 2} -def test_get_summary_feedback_for_session_invalid_user(public_client: TestClient): - response = public_client.get("/users/feedback/answersession=1/summary") - assert response.status_code == 401 +def test_get_summary_feedback_for_session_invalid(user_client: TestClient): + response = user_client.get("/users/feedback/answersession=12/summary") + assert response.status_code == 404 def test_get_detailed_feedback_for_session(user_client: TestClient): response = user_client.get("/users/feedback/answersession=1/detailed") assert response.status_code == 200 - assert response.json() == {"1": {"1": 0, "2": 0}} + assert response.json() == {"1": {"1": 2, "2": 2}} -def test_get_detailed_feedback_for_session_invalid_user(public_client: TestClient): - response = public_client.get("/users/feedback/answersession=1/detailed") - assert response.status_code == 401 - - -def test_get_detailed_feedback_for_milestonegroup(user_client: TestClient): - response = user_client.get( - "users/feedback/answersession=1/milestonegroup=1/detailed" - ) - assert response.status_code == 200 - assert response.json() == {"1": 0, "2": 0} +def test_get_detailed_feedback_for_session_invalid(user_client: TestClient): + response = user_client.get("/users/feedback/answersession=12/detailed") + assert response.status_code == 404 diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_scores.py index 226e1184..13d579ed 100644 --- a/mondey_backend/tests/utils/test_scores.py +++ b/mondey_backend/tests/utils/test_scores.py @@ -1,24 +1,19 @@ +from datetime import datetime +from datetime import timedelta + +import numpy as np from sqlmodel import select -from mondey_backend.models.children import Child from mondey_backend.models.milestones import MilestoneAgeScore +from mondey_backend.models.milestones import MilestoneAgeScoreCollection from mondey_backend.models.milestones import MilestoneAnswerSession -from mondey_backend.routers.scores import ( - compute_detailed_milestonegroup_feedback_for_all_sessions, -) -from mondey_backend.routers.scores import ( - compute_detailed_milestonegroup_feedback_for_answersession, -) +from mondey_backend.models.milestones import MilestoneGroupAgeScore +from mondey_backend.models.milestones import MilestoneGroupAgeScoreCollection +from mondey_backend.routers.scores import TrafficLight from mondey_backend.routers.scores import compute_feedback_simple -from mondey_backend.routers.scores import ( - compute_summary_milestonegroup_feedback_for_all_sessions, -) -from mondey_backend.routers.scores import ( - compute_summary_milestonegroup_feedback_for_answersession, -) -from mondey_backend.routers.scores import get_milestonegroups_for_answersession -from mondey_backend.routers.utils import _session_has_expired -from mondey_backend.users import fastapi_users +from mondey_backend.routers.scores import compute_milestonegroup_feedback_detailed +from mondey_backend.routers.scores import compute_milestonegroup_feedback_summary +from mondey_backend.routers.utils import get_milestonegroups_for_answersession def test_get_milestonegroups_for_answersession(session): @@ -51,94 +46,124 @@ def test_compute_feedback_simple(): score = 3 assert compute_feedback_simple(dummy_scores, score) == 2 - -def test_compute_detailed_milestonegroup_feedback_for_answersession(session): - answersession = session.get(MilestoneAnswerSession, 1) - - child = session.exec(select(Child).where(Child.user_id == 1)).first() - result = compute_detailed_milestonegroup_feedback_for_answersession( - session, answersession, child + dummy_scores = MilestoneGroupAgeScore( + milestonegroup_id=1, + age_months=8, + avg_score=3.0, + stddev_score=1.2, ) - assert result == {1: {1: 2, 2: 2}} - -def test_compute_detailed_milestonegroup_feedback_for_answersession_no_data(session): - answersession = session.get(MilestoneAnswerSession, 3) - child = session.exec(select(Child).where(Child.user_id == 3)).first() - result = compute_detailed_milestonegroup_feedback_for_answersession( - session, answersession, child +def test_compute_summary_milestonegroup_feedback_for_answersession_with_recompute( + statistics_session, +): + old_entries = statistics_session.exec( + select(MilestoneGroupAgeScoreCollection) + ).all() + assert len(old_entries) == 1 + for entry in old_entries: + assert entry.created_at < datetime.now() - timedelta( + hours=1 + ) # can be at max 1 hour old + + # there is an existing statistics for milestonegroup 1, which has milestones 1 and 2 + # which gives mean = 1.92 and stddev = 0.21, and we have 2 additional answers for these m + # milestones with answers 3 and 2 for milestones 1 and 2 respectively. ==> statistics + # changes to mean = 2.446 +/- 0.89. The first call updates the statistics with the new + # values, the second does not. + feedback = compute_milestonegroup_feedback_summary( + statistics_session, child_id=1, answersession_id=1 ) - assert result == {} - -def test_compute_summary_milestonegroup_feedback_for_answersession(session): - answersession = session.get(MilestoneAnswerSession, 1) - child = session.exec(select(Child).where(Child.user_id == 3)).first() + assert feedback[1] == TrafficLight.yellow.value + assert len(feedback) == 1 - result = compute_summary_milestonegroup_feedback_for_answersession( - session, answersession, child, age_limit_low=6, age_limit_high=6 + # same as above, but for answers 4, 3 -> 3.5 ==> green + feedback = compute_milestonegroup_feedback_summary( + statistics_session, child_id=1, answersession_id=2 ) - assert result == {1: 0} - - -def test_compute_summary_milestonegroup_feedback_for_answersession_no_data(session): - answersession = session.get(MilestoneAnswerSession, 3) - child = session.exec(select(Child).where(Child.user_id == 3)).first() - - result = compute_summary_milestonegroup_feedback_for_answersession( - session, answersession, child, age_limit_low=6, age_limit_high=6 + assert len(feedback) == 1 + assert feedback[1] == TrafficLight.green.value + new_entries = statistics_session.exec( + select(MilestoneGroupAgeScoreCollection) + ).all() + assert len(new_entries) == len(old_entries) + for old, new in zip(old_entries, new_entries, strict=True): + assert new.created_at >= datetime.now() - timedelta( + hours=1 + ) # can be at max 1 hour old + assert old.milestone_group_id == new.milestone_group_id + + +def test_compute_summary_milestonegroup_feedback_for_answersession_no_existing_stat( + statistics_session, +): + # there is only 2 answer sfor milestonegroup 2 which only has milestone 7. + # these 2 are from 2 answersessions which are 10 days apart so fall into the + # same age group => the feedback has only one entry for milestonegroup 2 + # and because the answers are 3 and 2 -> avg = 2.5 +/- 0.7071 -> green for answer = 3 + feedback = compute_milestonegroup_feedback_summary( + statistics_session, child_id=3, answersession_id=3 ) - assert result == {} - - -def test_compute_summary_milestonegroup_feedback_for_all_sessions(session): - child = session.exec(select(Child).where(Child.user_id == 3)).first() + assert len(feedback) == 1 + assert feedback[2] == TrafficLight.green.value - result = compute_summary_milestonegroup_feedback_for_all_sessions( - session, child, age_limit_low=6, age_limit_high=6 - ) - - relevant_answersession = list( - filter( - lambda a: _session_has_expired(a), - session.exec( - select(MilestoneAnswerSession) - .where(MilestoneAnswerSession.child_id == child.id) - .where(MilestoneAnswerSession.user_id == 3) - ).all(), + # check that the statistics have been updated + statistics = statistics_session.exec( + select(MilestoneGroupAgeScoreCollection).where( + MilestoneGroupAgeScoreCollection.milestone_group_id == 2 ) + ).all() + assert len(statistics) == 1 + assert statistics[0].created_at >= datetime.now() - timedelta( + minutes=1 + ) # can be at max 1 min old + + assert statistics[0].scores[42].count == 2 + assert np.isclose(statistics[0].scores[42].avg_score, 2.5) + assert np.isclose(statistics[0].scores[42].stddev_score, 0.7071) + + for i, score in enumerate(statistics[0].scores): + if i != 42: + assert np.isclose(score.avg_score, 0) + assert np.isclose(score.stddev_score, 0) + assert np.isclose(score.count, 0) + + +def test_compute_detailed_milestonegroup_feedback_for_answersession_with_recompute( + statistics_session, +): + old_entries = statistics_session.exec(select(MilestoneAgeScoreCollection)).all() + assert len(old_entries) == 2 + for entry in old_entries: + assert entry.created_at < datetime.now() - timedelta( + hours=1 + ) # can be at max 1 hour old + + feedback = compute_milestonegroup_feedback_detailed( + statistics_session, child_id=1, answersession_id=1 ) - expected_result = { - answersession.created_at.strftime("%d-%m-%Y"): {1: 0} - for answersession in relevant_answersession - } - assert len(result) == len(relevant_answersession) - - assert result == expected_result - - -def test_compute_detailed_milestonegroup_feedback_for_all_sessions(session): - child = session.exec(select(Child).where(Child.user_id == 3)).first() - user = fastapi_users.current_user(active=True) - - result = compute_detailed_milestonegroup_feedback_for_all_sessions( - session, user, child - ) - relevant_answersession = list( - filter( - lambda a: _session_has_expired(a), - session.exec( - select(MilestoneAnswerSession) - .where(MilestoneAnswerSession.child_id == child.id) - .where(MilestoneAnswerSession.user_id == 3) - ).all(), - ) + assert len(feedback) == 1 + assert len(feedback[1]) == 2 + assert feedback[1][1] == TrafficLight.green.red.value + assert feedback[1][2] == TrafficLight.green.red.value + new_entries = statistics_session.exec(select(MilestoneAgeScoreCollection)).all() + assert len(new_entries) == 2 + for old, new in zip(old_entries, new_entries, strict=True): + assert new.created_at >= datetime.now() - timedelta( + hours=1 + ) # can be at max 1 hour old + assert old.milestone_id == new.milestone_id + + +def test_compute_detailed_milestonegroup_feedback_for_answersession_no_existing_stat( + statistics_session, +): + # follows the same logic as the corresponding test for the milestonegroup summary feedback + feedback = compute_milestonegroup_feedback_detailed( + statistics_session, child_id=3, answersession_id=3 ) - expected_result = { - answersession.created_at.strftime("%d-%m-%Y"): {1: {1: 0, 2: 0}} - for answersession in relevant_answersession - } - assert len(result) == len(relevant_answersession) - assert result == expected_result + + assert len(feedback) == 1 + assert feedback[2][7] == TrafficLight.green.green.value diff --git a/mondey_backend/tests/utils/test_statistics.py b/mondey_backend/tests/utils/test_statistics.py new file mode 100644 index 00000000..1efedef9 --- /dev/null +++ b/mondey_backend/tests/utils/test_statistics.py @@ -0,0 +1,282 @@ +import datetime + +import numpy as np +import pytest +from sqlmodel import select + +from mondey_backend.models.milestones import MilestoneAnswer +from mondey_backend.models.milestones import MilestoneGroup +from mondey_backend.routers.statistics import _add_sample +from mondey_backend.routers.statistics import _finalize_statistics +from mondey_backend.routers.statistics import _get_statistics_by_age +from mondey_backend.routers.statistics import calculate_milestone_statistics_by_age +from mondey_backend.routers.statistics import calculate_milestonegroup_statistics_by_age + + +def test_online_statistics_computation(): + data = np.random.normal(0, 1, 200) + data_first = data[0:100] + data_second = data[100:200] + + count = 0 + avg = 0.0 + var = 0.0 + + for v in data_first: + count, avg, var = _add_sample(count, avg, var, v) + + count, avg, std = _finalize_statistics(count, avg, var) + + assert count == len(data_first) + assert np.isclose(avg, np.mean(data_first)) + assert np.isclose(std, np.std(data_first, ddof=1)) + + for v in data_second: + count, avg, var = _add_sample(count, avg, var, v) + + count, avg, std = _finalize_statistics(count, avg, var) + + assert count == len(data) + assert np.isclose(avg, np.mean(data)) + assert np.isclose(std, np.std(data, ddof=1)) + + +def test_online_statistics_computation_too_little_data(): + data = [ + 2.42342, + ] + count = 0 + avg = 0 + var = 0 + for v in data: + count, avg, var = _add_sample(count, avg, var, v) + count, avg, std = _finalize_statistics(count, avg, var) + + assert count == 1 + assert np.isclose(avg, 2.42342) + assert std == 0 + + data = np.array([]) + count = 0 + avg = 0.0 + var = 0.0 + for v in data: + count, avg, var = _add_sample(count, avg, var, v) + count, avg, std = _finalize_statistics(count, avg, var) + + assert count == 0 + assert avg == 0 + assert std == 0 + + data = [1, 2, 3] + count = 0 + avg = 0 + var = 0 + for v in data: + count, avg, var = _add_sample(count, avg, var, v) + + var = set( + [ + var, + ] + ) # wrong type + with pytest.raises( + ValueError, + match="Given values for statistics computation must be of type int|float|np.ndarray", + ): + count, avg, std = _finalize_statistics(count, avg, var) + + +def test_get_score_statistics_by_age(session): + answers = session.exec(select(MilestoneAnswer)).all() + print(answers) + # which answers we choose here is arbitrary for testing, we just need to make sure it's fixed and not empty + child_ages = { + 1: 5, + 2: 3, + 3: 8, + } + + count, avg, stddev = _get_statistics_by_age(answers, child_ages) + + answers_5 = [ + answer.answer + 1 for answer in answers if answer.answer_session_id == 1 + ] + answers_3 = [ + answer.answer + 1 for answer in answers if answer.answer_session_id == 2 + ] + answers_8 = [ + answer.answer + 1 for answer in answers if answer.answer_session_id == 3 + ] + + assert count[5] == 2 + assert count[3] == 2 + assert count[8] == 1 + + assert np.isclose(avg[5], 1.5) + assert np.isclose(avg[3], 3.5) + assert np.isclose(avg[8], 3.0) + + assert np.isclose( + stddev[5], + np.std( + answers_5, + ddof=1, + ), + ) + + assert np.isclose( + stddev[3], + np.std( + answers_3, + ddof=1, + ), + ) + + assert np.isclose( + stddev[8], + np.nan_to_num( + np.std( + answers_8, + ddof=1, + ) + ), + ) + + # check that updating works correctly. This is not done for all sessions + second_answers = answers + for answer in second_answers: + answer.answer += 1 if answer.answer != 3 else -1 + answers_5.extend( + [ + answer.answer + 1 + for answer in second_answers + if answer.answer_session_id in [1, 4] + ] + ) + answers_3.extend( + [ + answer.answer + 1 + for answer in second_answers + if answer.answer_session_id == 2 + ] + ) + answers_8.extend( + [ + answer.answer + 1 + for answer in second_answers + if answer.answer_session_id == 3 + ] + ) + + count, avg, stddev = _get_statistics_by_age( + second_answers, child_ages, count, avg, stddev + ) + + assert count[5] == 4 + assert count[3] == 4 + assert count[8] == 2 + assert np.isclose(avg[5], np.mean(answers_5)) + assert np.isclose(avg[3], np.mean(answers_3)) + assert np.isclose(avg[8], np.mean(answers_8)) + + assert np.isclose(stddev[5], np.std(answers_5, ddof=1)) + assert np.isclose(stddev[3], np.std(answers_3, ddof=1)) + assert np.isclose(stddev[8], np.std(answers_8, ddof=1)) + + +def test_get_score_statistics_by_age_no_data(statistics_session): + answers = statistics_session.exec(select(MilestoneAnswer)).all() + child_ages = {} # no answer sessions ==> empty child ages + count, avg, stddev = _get_statistics_by_age(answers, child_ages) + assert np.all(np.isclose(avg, 0)) + assert np.all(np.isclose(stddev, 0)) + + child_ages = {1: 5, 2: 3, 3: 8} + answers = [] # no answers ==> empty answers + count, avg, stddev = _get_statistics_by_age(answers, child_ages) + assert np.all(count == 0) + assert np.all(np.isclose(avg, 0)) + assert np.all(np.isclose(stddev, 0)) + + +def test_calculate_milestone_statistics_by_age(statistics_session): + # calculate_milestone_statistics_by_age + mscore = calculate_milestone_statistics_by_age(statistics_session, 1) + + # old statistics has avg[age=8] = 3.0 and stddev[age=8] = 0.35, and we + # get one more answer from answersession 4 with answer = 3 + assert mscore.milestone_id == 1 + assert mscore.scores[8].count == 13 + assert np.isclose(mscore.scores[8].avg_score, 3.0769) + assert np.isclose(mscore.scores[8].stddev_score, 0.27735) + + # we have nothing new for everything else + for age in range(0, len(mscore.scores)): + print( + age, + mscore.scores[age].count, + mscore.scores[age].avg_score, + mscore.scores[age].stddev_score, + ) + if age != 8: + assert mscore.scores[age].count == 12 + avg = 0 if age < 5 else min(1 * age - 5, 3) + assert np.isclose(mscore.scores[age].avg_score, avg) + stddev = 0.0 if age < 5 or age >= 8 else 0.35 + assert np.isclose(mscore.scores[age].stddev_score, stddev) + + if age < 8: + assert mscore.scores[age].expected_score == 1 + else: + assert mscore.scores[age].expected_score == 4 + + +def test_calculate_milestonegroup_statistics(statistics_session): + milestone_group = statistics_session.exec( + select(MilestoneGroup).where(MilestoneGroup.id == 1) + ).first() + + score = calculate_milestonegroup_statistics_by_age( + statistics_session, + milestone_group.id, + ) + + assert score.milestone_group_id == 1 + # no change for these ages + assert np.isclose(score.scores[5].avg_score, 1.2) + assert np.isclose(score.scores[6].avg_score, 1.44) + assert np.isclose(score.scores[7].avg_score, 1.68) + assert np.isclose(score.scores[9].avg_score, 2.16) + assert np.isclose(score.scores[10].avg_score, 2.4) + assert np.isclose(score.scores[11].avg_score, 2.64) + assert np.isclose(score.scores[12].avg_score, 2.88) + + for age in [ + 5, + 6, + 7, + ]: + assert np.isclose(score.scores[age].count, 4) # no change for this age + assert np.isclose( + score.scores[age].stddev_score, 0.21 + ) # no change for this age + + assert score.scores[8].count == 6 + assert np.isclose( + score.scores[8].avg_score, 2.446666 + ) # new answers from answersession 4 -> changed value + assert np.isclose( + score.scores[8].stddev_score, 0.890037 + ) # new answers from answersession 4 -> changed value + assert score.scores[8].age == 8 + assert score.scores[8].milestone_group_id == 1 + assert score.created_at - datetime.datetime.now() < datetime.timedelta( + minutes=1 + ) # allow for very slow machine in CI + + for age in range(0, len(score.scores)): + if age not in [5, 6, 7, 8]: + assert score.scores[age].count == 0 + if age > 12: + assert np.isclose(score.scores[age].avg_score, 3.0) diff --git a/mondey_backend/tests/utils/test_utils.py b/mondey_backend/tests/utils/test_utils.py index 7fbe5d6f..488f696b 100644 --- a/mondey_backend/tests/utils/test_utils.py +++ b/mondey_backend/tests/utils/test_utils.py @@ -1,14 +1,8 @@ -import numpy as np -from numpy import isclose from sqlmodel import select -from mondey_backend.models.milestones import MilestoneAnswer from mondey_backend.models.milestones import MilestoneAnswerSession from mondey_backend.models.milestones import MilestoneGroup from mondey_backend.routers.utils import _get_answer_session_child_ages_in_months -from mondey_backend.routers.utils import _get_score_statistics_by_age -from mondey_backend.routers.utils import calculate_milestone_statistics_by_age -from mondey_backend.routers.utils import calculate_milestonegroup_statistics from mondey_backend.routers.utils import get_milestonegroups_for_answersession @@ -29,113 +23,8 @@ def test_get_milestonegroups_for_answersession(session): def test_get_answer_session_child_ages_in_months(session): child_ages = _get_answer_session_child_ages_in_months(session) + assert len(child_ages) == 3 - assert child_ages[2] == 9 assert child_ages[1] == 8 + assert child_ages[2] == 9 assert child_ages[3] == 42 - - -def test_get_score_statistics_by_age(session): - answers = session.exec(select(MilestoneAnswer)).all() - child_ages = {1: 5, 2: 3, 3: 8} - - avg, stddev = _get_score_statistics_by_age(answers, child_ages) - - assert isclose(avg[5], 1.5) - assert isclose(avg[3], 3.5) - assert isclose(avg[8], 3.0) - - assert np.isclose( - stddev[5], - np.std( - [answer.answer + 1 for answer in answers if answer.answer_session_id == 1], - ddof=1, - ), - ) - - assert np.isclose( - stddev[3], - np.std( - [answer.answer + 1 for answer in answers if answer.answer_session_id == 2], - ddof=1, - ), - ) - - assert np.isclose( - stddev[8], - np.nan_to_num( - np.std( - [ - answer.answer + 1 - for answer in answers - if answer.answer_session_id == 3 - ], - ddof=1, - ) - ), - ) - - child_ages = {} # no answer sessions ==> empty child ages - avg, stddev = _get_score_statistics_by_age(answers, child_ages) - assert np.all(np.isclose(avg, 0)) - assert np.all(np.isclose(stddev, 0)) - - child_ages = {1: 5, 2: 3, 3: 8} - answers = [] # no answers ==> empty answers - avg, stddev = _get_score_statistics_by_age(answers, child_ages) - assert np.all(np.isclose(avg, 0)) - assert np.all(np.isclose(stddev, 0)) - - -def test_calculate_milestone_statistics_by_age(session): - # calculate_milestone_statistics_by_age - mscore = calculate_milestone_statistics_by_age(session, 1) - - # only some are filled - assert np.isclose(mscore.scores[8].avg_score, 2.0) - assert np.isclose(mscore.scores[8].stddev_score, 0.0) - assert np.isclose(mscore.scores[9].avg_score, 4.0) - assert np.isclose(mscore.scores[9].stddev_score, 0.0) - - for score in mscore.scores: - if score.age_months not in [8, 9]: - assert np.isclose(score.avg_score, 0.0) - assert np.isclose(score.stddev_score, 0.0) - - if score.age_months > 8: - assert np.isclose(score.expected_score, 4.0) - else: - assert np.isclose(score.expected_score, 1.0) - - -def test_calculate_milestonegroup_statistics(session): - age = 8 - age_lower = 6 - age_upper = 11 - - milestone_group = session.exec( - select(MilestoneGroup).where(MilestoneGroup.id == 1) - ).first() - milestones = [ - m.id - for m in milestone_group.milestones - if age_lower <= m.expected_age_months <= age_upper - ] - answers = [ - a.answer - for a in session.exec(select(MilestoneAnswer)).all() - if a.milestone_id in milestones - ] - score = calculate_milestonegroup_statistics( - session, milestone_group.id, age, age_lower, age_upper - ) - assert score.age_months == 8 - assert score.group_id == 1 - assert np.isclose(score.avg_score, np.mean(np.array(answers) + 1)) - assert np.isclose( - score.stddev_score, - np.std( - answers, - correction=1, - ), - )