diff --git a/src/apps/activities/domain/conditions.py b/src/apps/activities/domain/conditions.py index c652d099166..dce8053b60e 100644 --- a/src/apps/activities/domain/conditions.py +++ b/src/apps/activities/domain/conditions.py @@ -4,6 +4,7 @@ from pydantic import Field, root_validator, validator +from apps.activities.errors import IncorrectMaxTimeRange, IncorrectMinTimeRange, IncorrectTimeRange from apps.shared.domain import PublicModel, PublicModelNoExtra @@ -152,7 +153,7 @@ def dict(self, *args, **kwargs): class TimePayload(PublicModel): - type: str | None = None + type: TimePayloadType | None = None value: datetime.time def dict(self, *args, **kwargs): @@ -163,14 +164,40 @@ def dict(self, *args, **kwargs): class SingleTimePayload(PublicModel): time: Optional[datetime.time] = None + max_value: Optional[datetime.time] = None + min_value: Optional[datetime.time] = None @root_validator(pre=True) def validate_time(cls, values: Dict[str, Any]) -> Dict[str, Any]: time_value = values.get("time") + max_time_value = values.get("max_value") + min_time_value = values.get("min_value") + if isinstance(time_value, dict): values["time"] = cls._dict_to_time(time_value) elif isinstance(time_value, str): values["time"] = cls._string_to_time(time_value) + if max_time_value and min_time_value: + if isinstance(max_time_value, dict): + max_time_value = cls._dict_to_time(max_time_value) + elif isinstance(max_time_value, str): + max_time_value = cls._string_to_time(max_time_value) + + if isinstance(min_time_value, dict): + min_time_value = cls._dict_to_time(min_time_value) + elif isinstance(min_time_value, str): + min_time_value = cls._string_to_time(min_time_value) + + if max_time_value < min_time_value: + raise IncorrectTimeRange() + + if min_time_value is not None: + if max_time_value is None: + raise IncorrectMaxTimeRange() + if max_time_value is not None: + if min_time_value is None: + raise IncorrectMinTimeRange() + return values def dict(self, *args, **kwargs) -> Dict[str, Any]: @@ -554,7 +581,7 @@ class EqualToDateCondition(_EqualToDateCondition): class EqualCondition(_EqualCondition): - payload: ValuePayload | ValueIndexPayload | TimePayload + payload: ValuePayload | ValueIndexPayload | TimePayload | SingleTimePayload class NotEqualToDateCondition(_NotEqualToDateCondition): diff --git a/src/apps/activities/domain/custom_validation.py b/src/apps/activities/domain/custom_validation.py index f2b44dd03d9..363f47f096e 100644 --- a/src/apps/activities/domain/custom_validation.py +++ b/src/apps/activities/domain/custom_validation.py @@ -1,6 +1,6 @@ from apps.activities.domain.response_type_config import PerformanceTaskType, ResponseType from apps.activities.domain.response_values import PhrasalTemplateFieldType -from apps.activities.domain.scores_reports import ReportType, SubscaleItemType +from apps.activities.domain.scores_reports import ReportType, Score, SubscaleItemType, SubscaleSetting from apps.activities.errors import ( IncorrectConditionItemError, IncorrectConditionItemIndexError, @@ -19,9 +19,14 @@ IncorrectSectionPrintItemTypeError, IncorrectSubscaleInsideSubscaleError, IncorrectSubscaleItemError, + SubscaleDoesNotExist, SubscaleInsideSubscaleError, + SubscaleItemDoesNotExist, SubscaleItemScoreError, SubscaleItemTypeError, + SubscaleItemTypeItemDoesNotExist, + SubscaleNameDoesNotExist, + SubscaleSettingDoesNotExist, ) @@ -68,6 +73,23 @@ def validate_item_flow(values: dict): return values +def validate_subscale_setting_match_reports(report: Score, subscale_setting: SubscaleSetting): + report_subscale_linked = report.subscale_name + subscales = subscale_setting.subscales + if not subscales: + raise SubscaleDoesNotExist() + + linked_subscale = next((subscale for subscale in subscales if subscale.name == report_subscale_linked), None) + if not linked_subscale: + raise SubscaleNameDoesNotExist() + elif not linked_subscale.items: + raise SubscaleItemDoesNotExist() + else: + has_non_subscale_items = any(item.type == SubscaleItemType.ITEM for item in linked_subscale.items) + if not has_non_subscale_items: + raise SubscaleItemTypeItemDoesNotExist() + + def validate_score_and_sections(values: dict): # noqa: C901 items = values.get("items", []) item_names = [item.name for item in items] @@ -83,6 +105,13 @@ def validate_score_and_sections(values: dict): # noqa: C901 for report in list(scores): score_item_ids.append(report.id) + if report.scoring_type == "score": + subscale_setting = values.get("subscale_setting") + if not subscale_setting: # report of type score exist then we need a subscale setting + raise SubscaleSettingDoesNotExist() + else: + validate_subscale_setting_match_reports(report, subscale_setting) + # check if all item names are same as values.name for item in report.items_score: if item not in item_names: diff --git a/src/apps/activities/domain/scores_reports.py b/src/apps/activities/domain/scores_reports.py index ed278bcd692..08544099e19 100644 --- a/src/apps/activities/domain/scores_reports.py +++ b/src/apps/activities/domain/scores_reports.py @@ -29,6 +29,11 @@ class CalculationType(enum.StrEnum): PERCENTAGE = "percentage" +class ScoringType(str, Enum): + SCORE = "score" + RAW_SCORE = "raw_score" + + class ScoreConditionalLogic(PublicModel): name: str id: str @@ -59,6 +64,8 @@ class Score(PublicModel): message: str | None = None items_print: list[str] | None = Field(default_factory=list) conditional_logic: list[ScoreConditionalLogic] | None = None + scoring_type: ScoringType | None = None + subscale_name: str | None = None @validator("conditional_logic") def validate_conditional_logic(cls, value, values): diff --git a/src/apps/activities/errors.py b/src/apps/activities/errors.py index 554f2e564df..a7a5b8d7484 100644 --- a/src/apps/activities/errors.py +++ b/src/apps/activities/errors.py @@ -260,6 +260,14 @@ class IncorrectTimeRange(ValidationError): message = _("Incorrect timerange") +class IncorrectMinTimeRange(ValidationError): + message = _("Mix timerange was not passed") + + +class IncorrectMaxTimeRange(ValidationError): + message = _("Max timerange was not passed") + + class FlowDoesNotExist(NotFoundError): message = _("Flow does not exist.") @@ -274,3 +282,38 @@ class IncorrectPhrasalTemplateItemTypeError(ValidationError): class IncorrectPhrasalTemplateItemIndexError(ValidationError): message = _("Invalid item index for activity item inside phrasal template") + + +class SubscaleIsNotLinked(ValidationError): + message = _("The scoring_type is score but no suscale_name string pased") + code = _("no_subscale_items_exist") + + +class SubscaleDoesNotExist(ValidationError): + message = _("The scoring_type is score but there are no subscales") + code = _("no_subscale_linked") + + +class SubscaleNameDoesNotExist(ValidationError): + message = _("The lookup table with the passed name does not exist in subscale settings") + code = _("no_subscale_name_exist") + + +class SubscaleDataDoesNotExist(ValidationError): + message = _("The scoring_type is score but the subscale data does not exist") + code = _("no_subscaledata_exist") + + +class SubscaleSettingDoesNotExist(ValidationError): + message = _("The scoring_type is score but there are no subscale settings associated with activity") + code = _("no_subscale_setting_exist") + + +class SubscaleItemDoesNotExist(ValidationError): + message = _("The linked subscale should contain at least one item") + code = _("no_items_exist") + + +class SubscaleItemTypeItemDoesNotExist(ValidationError): + message = _("The linked subscale should contain at least one non-subscale type item") + code = _("no_subscale_type_items_exist") diff --git a/src/apps/activities/tests/fixtures/scores_reports.py b/src/apps/activities/tests/fixtures/scores_reports.py index cf73250dfba..1898ef9824a 100644 --- a/src/apps/activities/tests/fixtures/scores_reports.py +++ b/src/apps/activities/tests/fixtures/scores_reports.py @@ -48,6 +48,30 @@ def score() -> Score: ) +@pytest.fixture +def score_with_subscale() -> Score: + return Score( + type=ReportType.score, + name="testscore type score", + id=SCORE_ID, + calculation_type=CalculationType.SUM, + scoring_type="score", + subscale_name="subscale type score", + ) + + +@pytest.fixture +def score_with_subscale_raw() -> Score: + return Score( + type=ReportType.score, + name="testscore type score", + id=SCORE_ID, + calculation_type=CalculationType.SUM, + scoring_type="raw_score", + subscale_name=None, + ) + + @pytest.fixture def section_conditional_logic() -> SectionConditionalLogic: return SectionConditionalLogic( @@ -76,6 +100,15 @@ def scores_and_reports(score: Score, section: Section) -> ScoresAndReports: ) +@pytest.fixture +def scores_and_reports_raw_score(score_with_subscale_raw: Score, section: Section) -> ScoresAndReports: + return ScoresAndReports( + generate_report=True, + show_score_summary=True, + reports=[score_with_subscale_raw, section], + ) + + @pytest.fixture def subscale_item() -> SubscaleItem: return SubscaleItem(name="activity_item_1", type=SubscaleItemType.ITEM) @@ -90,6 +123,26 @@ def subscale(subscale_item: SubscaleItem) -> Subscale: ) +@pytest.fixture +def subscale_score_type() -> Subscale: + return Subscale( + name="subscale type score", + scoring=SubscaleCalculationType.AVERAGE, + items=[SubscaleItem(name="subscale_item", type=SubscaleItemType.ITEM)], + ) + + +@pytest.fixture +def scores_and_reports_lookup_scores( + score_with_subscale: Score, section: Section, subscale: Subscale +) -> ScoresAndReports: + return ScoresAndReports( + generate_report=True, + show_score_summary=True, + reports=[score_with_subscale], + ) + + @pytest.fixture def subscale_item_type_subscale(subscale: Subscale) -> SubscaleItem: # Depends on subscalke because name should contain subscale item @@ -99,7 +152,9 @@ def subscale_item_type_subscale(subscale: Subscale) -> SubscaleItem: @pytest.fixture def subscale_with_item_type_subscale(subscale_item_type_subscale: SubscaleItem) -> Subscale: return Subscale( - name="subscale type subscale", items=[subscale_item_type_subscale], scoring=SubscaleCalculationType.AVERAGE + name="subscale type subscale", + scoring=SubscaleCalculationType.AVERAGE, + items=[subscale_item_type_subscale], ) @@ -111,6 +166,14 @@ def subscale_setting(subscale: Subscale) -> SubscaleSetting: ) +@pytest.fixture +def subscale_setting_score_type(subscale_score_type: Subscale) -> SubscaleSetting: + return SubscaleSetting( + calculate_total_score=SubscaleCalculationType.AVERAGE, + subscales=[subscale_score_type], + ) + + @pytest.fixture def subscale_total_score_table() -> list[TotalScoreTable]: return [ diff --git a/src/apps/activities/tests/unit/domain/test_activity_item_create.py b/src/apps/activities/tests/unit/domain/test_activity_item_create.py index eaa4a4a8782..7aab778d997 100644 --- a/src/apps/activities/tests/unit/domain/test_activity_item_create.py +++ b/src/apps/activities/tests/unit/domain/test_activity_item_create.py @@ -599,54 +599,138 @@ def test_create_message_item__sanitize_question(message_item_create): assert item.question["en"] == "One Two" -@pytest.mark.parametrize( - "response_type, config_fixture, cnd_logic_fixture, response_values_fixture", - ( - (ResponseType.SINGLESELECT, "single_select_config", "conditional_logic_equal", "single_select_response_values"), - (ResponseType.MULTISELECT, "multi_select_config", "conditional_logic_equal", "multi_select_response_values"), - (ResponseType.SLIDER, "slider_config", "conditional_logic_between", "slider_response_values"), - (ResponseType.TIME, "time_config", "conditional_logic_between", None), - (ResponseType.TIMERANGE, "time_range_config", "conditional_logic_between", None), - ( - ResponseType.NUMBERSELECT, - "number_selection_config", - "conditional_logic_between", - "number_selection_response_values", - ), - (ResponseType.DATE, "date_config", "conditional_logic_equal", None), - ( - ResponseType.SINGLESELECTROWS, - "single_select_row_config", - "conditional_logic_equal", - "single_select_row_response_values", - ), - ( - ResponseType.MULTISELECTROWS, - "multi_select_row_config", - "conditional_logic_equal", - "multi_select_row_response_values", - ), - ( - ResponseType.SLIDERROWS, - "slider_rows_config", - "conditional_logic_rows_outside_of", - "slider_rows_response_values", - ), - ), -) -def test_create_activity_item_conditional_logic( - base_item_data, request, response_type, config_fixture, cnd_logic_fixture, response_values_fixture -) -> None: - config = request.getfixturevalue(config_fixture) - cnd_logic = request.getfixturevalue(cnd_logic_fixture) - if response_values_fixture: - response_values = request.getfixturevalue(response_values_fixture) - else: - response_values = None +def test_single_select_logic(base_item_data, request) -> None: + config = request.getfixturevalue("single_select_config") + cnd_logic = request.getfixturevalue("conditional_logic_equal") + response_values = request.getfixturevalue("single_select_response_values") + + ActivityItemCreate( + **base_item_data.dict(), + config=config, + response_type=ResponseType.SINGLESELECT, + conditional_logic=cnd_logic, + response_values=response_values, + ) + + +def test_multi_select_logic(base_item_data, request) -> None: + config = request.getfixturevalue("multi_select_config") + cnd_logic = request.getfixturevalue("conditional_logic_equal") + response_values = request.getfixturevalue("multi_select_response_values") + + ActivityItemCreate( + **base_item_data.dict(), + config=config, + response_type=ResponseType.MULTISELECT, + conditional_logic=cnd_logic, + response_values=response_values, + ) + + +def test_slider_logic(base_item_data, request) -> None: + config = request.getfixturevalue("slider_config") + cnd_logic = request.getfixturevalue("conditional_logic_between") + response_values = request.getfixturevalue("slider_response_values") + + ActivityItemCreate( + **base_item_data.dict(), + config=config, + response_type=ResponseType.SLIDER, + conditional_logic=cnd_logic, + response_values=response_values, + ) + + +def test_time_logic(base_item_data, request) -> None: + config = request.getfixturevalue("time_config") + cnd_logic = request.getfixturevalue("conditional_logic_between") + ActivityItemCreate( **base_item_data.dict(), config=config, - response_type=response_type, + response_type=ResponseType.TIME, + conditional_logic=cnd_logic, + response_values=None, + ) + + +def test_time_range_logic(base_item_data, request) -> None: + config = request.getfixturevalue("time_range_config") + cnd_logic = request.getfixturevalue("conditional_logic_between") + + ActivityItemCreate( + **base_item_data.dict(), + config=config, + response_type=ResponseType.TIMERANGE, + conditional_logic=cnd_logic, + response_values=None, + ) + + +def test_number_select_logic(base_item_data, request) -> None: + config = request.getfixturevalue("number_selection_config") + cnd_logic = request.getfixturevalue("conditional_logic_between") + response_values = request.getfixturevalue("number_selection_response_values") + + ActivityItemCreate( + **base_item_data.dict(), + config=config, + response_type=ResponseType.NUMBERSELECT, + conditional_logic=cnd_logic, + response_values=response_values, + ) + + +def test_date_logic(base_item_data, request) -> None: + config = request.getfixturevalue("date_config") + cnd_logic = request.getfixturevalue("conditional_logic_equal") + + ActivityItemCreate( + **base_item_data.dict(), + config=config, + response_type=ResponseType.DATE, + conditional_logic=cnd_logic, + response_values=None, + ) + + +def test_single_select_row_logic(base_item_data, request) -> None: + config = request.getfixturevalue("single_select_row_config") + cnd_logic = request.getfixturevalue("conditional_logic_equal") + response_values = request.getfixturevalue("single_select_row_response_values") + + ActivityItemCreate( + **base_item_data.dict(), + config=config, + response_type=ResponseType.SINGLESELECTROWS, + conditional_logic=cnd_logic, + response_values=response_values, + ) + + +def test_multi_select_row_logic(base_item_data, request) -> None: + config = request.getfixturevalue("multi_select_row_config") + cnd_logic = request.getfixturevalue("conditional_logic_equal") + response_values = request.getfixturevalue("multi_select_row_response_values") + + ActivityItemCreate( + **base_item_data.dict(), + config=config, + response_type=ResponseType.MULTISELECTROWS, + conditional_logic=cnd_logic, + response_values=response_values, + ) + + +def test_slider_rows_logic(base_item_data, request) -> None: + config = request.getfixturevalue("slider_rows_config") + cnd_logic = request.getfixturevalue("conditional_logic_rows_outside_of") + response_values = request.getfixturevalue("slider_rows_response_values") + + ActivityItemCreate( + **base_item_data.dict(), + config=config, + response_type=ResponseType.SLIDERROWS, conditional_logic=cnd_logic, response_values=response_values, ) diff --git a/src/apps/activities/tests/unit/domain/test_custom_validation.py b/src/apps/activities/tests/unit/domain/test_custom_validation.py index ac50cd77b8e..331761bf84a 100644 --- a/src/apps/activities/tests/unit/domain/test_custom_validation.py +++ b/src/apps/activities/tests/unit/domain/test_custom_validation.py @@ -220,23 +220,21 @@ def test_validator_successful_create_outside_condition(self, payload): payload=payload, ) - @pytest.mark.parametrize( - "payload_type,payload,exp_exception", - ( - (SingleDatePayload, dict(date="1970-99-01"), ValidationError), - (SingleTimePayload, dict(time={"hours": 80, "minutes": 0}), ValueError), # Adjusted time dict - ( - SingleTimePayload, - dict(time={"hours": 3, "minutes": 0}, min_value="03:00", max_value="02:00"), - IncorrectTimeRange, - ), - (SingleTimePayload, dict(time={"hours": 3, "minutes": 0}), ValidationError), - (SingleTimePayload, dict(time="unknown_item_type", min_value="01:00", max_value="02:00"), ValidationError), - ), - ) - def test_fails_create_outside_condition(self, payload_type, payload, exp_exception): - with pytest.raises(exp_exception): - payload_type(**payload) + def test_single_date_payload_invalid_date(self): + with pytest.raises(ValidationError): + SingleDatePayload(date="1970-99-01") + + def test_single_time_payload_invalid_hours(self): + with pytest.raises(ValueError): + SingleTimePayload(time={"hours": 80, "minutes": 0}) + + def test_single_time_payload_incorrect_time_range(self): + with pytest.raises(IncorrectTimeRange): + SingleTimePayload(time={"hours": 3, "minutes": 0}, min_value="03:00", max_value="02:00") + + def test_single_time_payload_unknown_item_type(self): + with pytest.raises(ValidationError): + SingleTimePayload(time="unknown_item_type", min_value="01:00", max_value="02:00") class TestValidateScoreAndSections: diff --git a/src/apps/answers/service.py b/src/apps/answers/service.py index 655ca75400f..6f7f7553aeb 100644 --- a/src/apps/answers/service.py +++ b/src/apps/answers/service.py @@ -1953,7 +1953,9 @@ async def is_flow_finished(self, submit_id: uuid.UUID, answer_id: uuid.UUID) -> return self._is_activity_last_in_flow(applet_full, activity_id, flow_id) async def create_report( - self, submit_id: uuid.UUID, answer_id: uuid.UUID | None = None + self, + submit_id: uuid.UUID, + answer_id: uuid.UUID | None = None, ) -> ReportServerResponse | None: filters = dict(submit_id=submit_id) if answer_id: diff --git a/src/apps/applets/tests/test_applet_activity_items.py b/src/apps/applets/tests/test_applet_activity_items.py index edb9f546506..6b633d4f04b 100644 --- a/src/apps/applets/tests/test_applet_activity_items.py +++ b/src/apps/applets/tests/test_applet_activity_items.py @@ -16,6 +16,7 @@ from apps.activities.domain.scores_reports import ( ScoreConditionalLogic, ScoresAndReports, + ScoringType, SectionConditionalLogic, Subscale, SubScaleLookupTable, @@ -522,6 +523,38 @@ async def test_create_applet__activity_with_subscale_settings_with_subscale_look result = resp.json()["result"] assert result["activities"][0]["subscaleSetting"] == sub_setting.dict(by_alias=True) + async def test_create_applet__activity_with_subscale_settings_and_lookup_table_and_score_report_lookup_scoring( + self, + client: TestClient, + applet_minimal_data: AppletCreate, + single_select_item_create_with_score: ActivityItemCreate, + tom: User, + subscale_setting_score_type: SubscaleSetting, + subscale_lookup_table: list[SubScaleLookupTable], + scores_and_reports_lookup_scores: ScoresAndReports, + ): + client.login(tom) + data = applet_minimal_data.copy(deep=True) + sub_setting = subscale_setting_score_type.copy(deep=True) + + # Update subscale setting with item name and lookup table + sub_setting.subscales[0].items[0].name = single_select_item_create_with_score.name # type: ignore[index] + sub_setting.subscales[0].subscale_table_data = subscale_lookup_table # type: ignore[index] + + data.activities[0].items = [single_select_item_create_with_score] + data.activities[0].subscale_setting = sub_setting + data.activities[0].scores_and_reports = scores_and_reports_lookup_scores + + # Make the POST request + resp = await client.post(self.applet_create_url.format(owner_id=tom.id), data=data) + + # Assertions + assert resp.status_code == http.HTTPStatus.CREATED + result = resp.json()["result"] + assert result["activities"][0]["subscaleSetting"] == sub_setting.dict(by_alias=True) + assert result["activities"][0]["scoresAndReports"]["reports"][0]["scoringType"] == ScoringType.SCORE.value + assert result["activities"][0]["scoresAndReports"]["reports"][0]["subscaleName"] == "subscale type score" + async def test_create_applet__activity_with_subscale_settings_with_invalid_subscale_lookup_table_age( self, client: TestClient,