diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dc2cc965..d530b7bb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: rev: 6.3.0 hooks: - id: pydocstyle - exclude: *generated + exclude: "generated/|tests.py" additional_dependencies: [tomli] - repo: https://github.com/pycqa/isort diff --git a/canvas_sdk/commands/base.py b/canvas_sdk/commands/base.py index 8402447d..ca5ec35d 100644 --- a/canvas_sdk/commands/base.py +++ b/canvas_sdk/commands/base.py @@ -10,7 +10,7 @@ class _BaseCommand(BaseModel): - model_config = ConfigDict(strict=True, revalidate_instances="always") + model_config = ConfigDict(strict=True, revalidate_instances="always", validate_assignment=True) class Meta: key = "" @@ -42,18 +42,22 @@ def constantized_key(self) -> str: command_uuid: str | None = None user_id: int | None = None - def _create_error_detail(self, type: str, message: str, value: Any) -> InitErrorDetails: - return InitErrorDetails({"type": PydanticCustomError(type, message), "input": value}) - - def _get_error_details( + def _get_effect_method_required_fields( self, method: Literal["originate", "edit", "delete", "commit", "enter_in_error"] - ) -> list[InitErrorDetails]: + ) -> tuple[str]: base_required_fields: tuple = getattr( _BaseCommand.Meta, f"{method}_required_fields", tuple() ) command_required_fields: tuple = getattr(self.Meta, f"{method}_required_fields", tuple()) - required_fields = tuple(set(base_required_fields) | set(command_required_fields)) + return tuple(set(base_required_fields) | set(command_required_fields)) + def _create_error_detail(self, type: str, message: str, value: Any) -> InitErrorDetails: + return InitErrorDetails({"type": PydanticCustomError(type, message), "input": value}) + + def _get_error_details( + self, method: Literal["originate", "edit", "delete", "commit", "enter_in_error"] + ) -> list[InitErrorDetails]: + required_fields = self._get_effect_method_required_fields(method) return [ self._create_error_detail( "missing", f"Field '{field}' is required to {method.replace('_', ' ')} a command", v @@ -98,9 +102,10 @@ def command_schema(cls) -> dict: """The schema of the command.""" base_properties = {"note_uuid", "command_uuid", "user_id"} schema = cls.model_json_schema() + required_fields: tuple = getattr(cls.Meta, "originate_required_fields", tuple()) return { definition.get("commands_api_name", name): { - "required": name in schema["required"], + "required": name in required_fields, "type": cls._get_property_type(name), "choices": cls._get_property_choices(name, schema), } diff --git a/canvas_sdk/commands/commands/goal.py b/canvas_sdk/commands/commands/goal.py index 64f297d1..3e67afc0 100644 --- a/canvas_sdk/commands/commands/goal.py +++ b/canvas_sdk/commands/commands/goal.py @@ -9,7 +9,7 @@ class GoalCommand(_BaseCommand): class Meta: key = "goal" - originate_required_fields = ("goal_statement",) + originate_required_fields = ("goal_statement", "start_date") class Priority(Enum): HIGH = "high-priority" diff --git a/canvas_sdk/commands/commands/history_present_illness.py b/canvas_sdk/commands/commands/history_present_illness.py index 95b6c03a..1eff7d4d 100644 --- a/canvas_sdk/commands/commands/history_present_illness.py +++ b/canvas_sdk/commands/commands/history_present_illness.py @@ -8,7 +8,7 @@ class Meta: key = "hpi" originate_required_fields = ("narrative",) - narrative: str | None + narrative: str | None = None @property def values(self) -> dict: diff --git a/canvas_sdk/commands/commands/prescribe.py b/canvas_sdk/commands/commands/prescribe.py index b2181506..0e4340c8 100644 --- a/canvas_sdk/commands/commands/prescribe.py +++ b/canvas_sdk/commands/commands/prescribe.py @@ -14,6 +14,7 @@ class Meta: originate_required_fields = ( "fdb_code", "sig", + "quantity_to_dispense", "type_to_dispense", "refills", "substitutions", @@ -33,7 +34,7 @@ class Substitutions(Enum): quantity_to_dispense: Decimal | float | int | None = None type_to_dispense: str | None = None refills: int | None = None - substitutions: Substitutions = Substitutions.ALLOWED # type: ignore + substitutions: Substitutions | None = None pharmacy: str | None = None prescriber_id: str | None = Field( default=None, json_schema_extra={"commands_api_name": "prescriber"} @@ -53,7 +54,7 @@ def values(self) -> dict: ), # "type_to_dispense": self.type_to_dispense, "refills": self.refills, - "substitutions": self.substitutions.value, + "substitutions": self.substitutions.value if self.substitutions else None, "pharmacy": self.pharmacy, "prescriber_id": self.prescriber_id, "note_to_pharmacist": self.note_to_pharmacist, diff --git a/canvas_sdk/commands/tests/test_utils.py b/canvas_sdk/commands/tests/test_utils.py index 2ba98b91..4e78a667 100644 --- a/canvas_sdk/commands/tests/test_utils.py +++ b/canvas_sdk/commands/tests/test_utils.py @@ -77,35 +77,7 @@ def fake( return random.choice([e for e in getattr(Command, t)]) -def raises_missing_error( - base: dict, - Command: ( - AssessCommand - | DiagnoseCommand - | GoalCommand - | HistoryOfPresentIllnessCommand - | MedicationStatementCommand - | PlanCommand - | PrescribeCommand - | QuestionnaireCommand - | ReasonForVisitCommand - | StopMedicationCommand - ), - field: str, -) -> None: - err_kwargs = base.copy() - err_kwargs.pop(field) - with pytest.raises(ValidationError) as e: - Command(**err_kwargs) - err_msg = repr(e.value) - assert ( - f"1 validation error for {Command.__name__}\n{field}\n Field required [type=missing" - in err_msg - ) - - -def raises_none_error( - base: dict, +def raises_wrong_type_error( Command: ( AssessCommand | DiagnoseCommand @@ -122,21 +94,24 @@ def raises_none_error( ) -> None: field_props = Command.model_json_schema()["properties"][field] field_type = get_field_type(field_props) + wrong_field_type = "integer" if field_type == "string" else "string" with pytest.raises(ValidationError) as e1: - err_kwargs = base | {field: None} + err_kwargs = {field: fake({"type": wrong_field_type}, Command)} Command(**err_kwargs) err_msg1 = repr(e1.value) - valid_kwargs = base | {field: fake(field_props, Command)} + valid_kwargs = {field: fake(field_props, Command)} cmd = Command(**valid_kwargs) + err_value = fake({"type": wrong_field_type}, Command) with pytest.raises(ValidationError) as e2: - setattr(cmd, field, None) + setattr(cmd, field, err_value) err_msg2 = repr(e2.value) assert f"1 validation error for {Command.__name__}\n{field}" in err_msg1 assert f"1 validation error for {Command.__name__}\n{field}" in err_msg2 + field_type = "dictionary" if field_type == "Coding" else field_type if field_type == "number": assert f"Input should be an instance of Decimal" in err_msg1 assert f"Input should be an instance of Decimal" in err_msg2 @@ -148,8 +123,7 @@ def raises_none_error( assert f"Input should be a valid {field_type}" in err_msg2 -def raises_wrong_type_error( - base: dict, +def raises_none_error_for_effect_method( Command: ( AssessCommand | DiagnoseCommand @@ -162,34 +136,16 @@ def raises_wrong_type_error( | ReasonForVisitCommand | StopMedicationCommand ), - field: str, + method: str, ) -> None: - field_props = Command.model_json_schema()["properties"][field] - field_type = get_field_type(field_props) - wrong_field_type = "integer" if field_type == "string" else "string" - - with pytest.raises(ValidationError) as e1: - err_kwargs = base | {field: fake({"type": wrong_field_type}, Command)} - Command(**err_kwargs) - err_msg1 = repr(e1.value) - - valid_kwargs = base | {field: fake(field_props, Command)} - cmd = Command(**valid_kwargs) - err_value = fake({"type": wrong_field_type}, Command) - with pytest.raises(ValidationError) as e2: - setattr(cmd, field, err_value) - err_msg2 = repr(e2.value) - - assert f"1 validation error for {Command.__name__}\n{field}" in err_msg1 - assert f"1 validation error for {Command.__name__}\n{field}" in err_msg2 - - field_type = "dictionary" if field_type == "Coding" else field_type - if field_type == "number": - assert f"Input should be an instance of Decimal" in err_msg1 - assert f"Input should be an instance of Decimal" in err_msg2 - elif field_type[0].isupper(): - assert f"Input should be an instance of {Command.__name__}.{field_type}" in err_msg1 - assert f"Input should be an instance of {Command.__name__}.{field_type}" in err_msg2 - else: - assert f"Input should be a valid {field_type}" in err_msg1 - assert f"Input should be a valid {field_type}" in err_msg2 + cmd = Command() + method_required_fields = cmd._get_effect_method_required_fields(method) + with pytest.raises(ValidationError) as e: + getattr(cmd, method)() + e_msg = repr(e.value) + assert f"{len(method_required_fields)} validation errors for {Command.__name__}" in e_msg + for f in method_required_fields: + assert ( + f"Field '{f}' is required to {method.replace('_', ' ')} a command [type=missing, input_value=None, input_type=NoneType]" + in e_msg + ) diff --git a/canvas_sdk/commands/tests/tests.py b/canvas_sdk/commands/tests/tests.py index 27d49549..400920d7 100644 --- a/canvas_sdk/commands/tests/tests.py +++ b/canvas_sdk/commands/tests/tests.py @@ -1,3 +1,4 @@ +import decimal from datetime import datetime import pytest @@ -22,8 +23,7 @@ from canvas_sdk.commands.tests.test_utils import ( fake, get_field_type, - raises_missing_error, - raises_none_error, + raises_none_error_for_effect_method, raises_wrong_type_error, ) @@ -96,15 +96,11 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type( ), fields_to_test: tuple[str], ) -> None: - schema = Command.model_json_schema() - schema["required"].append("note_uuid") - required_fields = {k: v for k, v in schema["properties"].items() if k in schema["required"]} - base = {field: fake(props, Command) for field, props in required_fields.items()} for field in fields_to_test: - raises_wrong_type_error(base, Command, field) - if field in required_fields: - raises_missing_error(base, Command, field) - raises_none_error(base, Command, field) + raises_wrong_type_error(Command, field) + + for method in ["originate", "edit", "delete", "commit", "enter_in_error"]: + raises_none_error_for_effect_method(Command, method) @pytest.mark.parametrize( @@ -112,26 +108,26 @@ def test_command_raises_generic_error_when_kwarg_given_incorrect_type( [ ( PlanCommand, - {"narrative": "yo", "user_id": 1}, - "1 validation error for PlanCommand\n Value error, Command should have either a note_uuid or a command_uuid. [type=value", + {"narrative": "yo", "user_id": 5, "note_uuid": 1}, + "1 validation error for PlanCommand\nnote_uuid\n Input should be a valid string [type=string_type", {"narrative": "yo", "note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1}, ), ( PlanCommand, - {"narrative": "yo", "user_id": 1, "note_uuid": None}, - "1 validation error for PlanCommand\n Value error, Command should have either a note_uuid or a command_uuid. [type=value", - {"narrative": "yo", "note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1}, + {"narrative": "yo", "user_id": 5, "note_uuid": "5", "command_uuid": 5}, + "1 validation error for PlanCommand\ncommand_uuid\n Input should be a valid string [type=string_type", + {"narrative": "yo", "user_id": 5, "note_uuid": "5", "command_uuid": "5"}, ), ( PlanCommand, - {"narrative": "yo", "user_id": 5, "note_uuid": 1}, - "1 validation error for PlanCommand\nnote_uuid\n Input should be a valid string [type=string_type", - {"narrative": "yo", "note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1}, + {"narrative": "yo", "note_uuid": "5", "command_uuid": "4", "user_id": "5"}, + "1 validation error for PlanCommand\nuser_id\n Input should be a valid integer [type=int_type", + {"narrative": "yo", "note_uuid": "5", "command_uuid": "4", "user_id": 5}, ), ( ReasonForVisitCommand, {"note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1, "structured": True}, - "1 validation error for ReasonForVisitCommand\n Value error, Structured RFV should have a coding.", + "1 validation error for ReasonForVisitCommand\n Structured RFV should have a coding", { "note_uuid": "00000000-0000-0000-0000-000000000000", "user_id": 1, @@ -217,7 +213,9 @@ def test_command_raises_specific_error_when_kwarg_given_incorrect_type( valid_kwargs: dict, ) -> None: with pytest.raises(ValidationError) as e1: - Command(**err_kwargs) + cmd = Command(**err_kwargs) + cmd.originate() + cmd.edit() assert err_msg in repr(e1.value) cmd = Command(**valid_kwargs) @@ -226,6 +224,8 @@ def test_command_raises_specific_error_when_kwarg_given_incorrect_type( key, value = list(err_kwargs.items())[-1] with pytest.raises(ValidationError) as e2: setattr(cmd, key, value) + cmd.originate() + cmd.edit() assert err_msg in repr(e2.value) @@ -299,15 +299,12 @@ def test_command_allows_kwarg_with_correct_type( fields_to_test: tuple[str], ) -> None: schema = Command.model_json_schema() - schema["required"].append("note_uuid") - required_fields = {k: v for k, v in schema["properties"].items() if k in schema["required"]} - base = {field: fake(props, Command) for field, props in required_fields.items()} for field in fields_to_test: - field_type = get_field_type(Command.model_json_schema()["properties"][field]) + field_type = get_field_type(schema["properties"][field]) init_field_value = fake({"type": field_type}, Command) - init_kwargs = base | {field: init_field_value} + init_kwargs = {field: init_field_value} cmd = Command(**init_kwargs) assert getattr(cmd, field) == init_field_value @@ -315,6 +312,17 @@ def test_command_allows_kwarg_with_correct_type( setattr(cmd, field, updated_field_value) assert getattr(cmd, field) == updated_field_value + for method in ["originate", "edit", "delete", "commit", "enter_in_error"]: + required_fields = { + k: v + for k, v in schema["properties"].items() + if k in Command()._get_effect_method_required_fields(method) + } + base = {field: fake(props, Command) for field, props in required_fields.items()} + cmd = Command(**base) + effect = getattr(cmd, method)() + assert effect is not None + @pytest.fixture(scope="session") def token() -> str: @@ -357,6 +365,9 @@ def command_type_map() -> dict[str, type]: "TextField": str, "ChoiceField": str, "DateField": datetime, + "ApproximateDateField": datetime, + "IntegerField": int, + "DecimalField": decimal.Decimal, } @@ -365,14 +376,12 @@ def command_type_map() -> dict[str, type]: "Command", [ (AssessCommand), - # todo: add Diagnose once it has an adapter in home-app - # (DiagnoseCommand), + (DiagnoseCommand), (GoalCommand), (HistoryOfPresentIllnessCommand), (MedicationStatementCommand), (PlanCommand), - # todo: add Prescribe once its been refactored - # (PrescribeCommand), + (PrescribeCommand), (QuestionnaireCommand), (ReasonForVisitCommand), (StopMedicationCommand), @@ -427,7 +436,15 @@ def test_command_schema_matches_command_api( expected_type = expected_field["type"] if expected_type is Coding: expected_type = expected_type.__annotations__["code"] - assert expected_type == command_type_map.get(actual_field["type"]) + + actual_type = command_type_map.get(actual_field["type"]) + if actual_field["type"] == "AutocompleteField" and name[-1] == "s": + # this condition initially created for Prescribe.indications, + # but could apply to other AutocompleteField fields that are lists + # making the assumption here that if the field ends in 's' (like indications), it is a list + actual_type = list[actual_type] # type: ignore + + assert expected_type == actual_type if (choices := actual_field["choices"]) is None: assert expected_field["choices"] is None