From a60d193a4319457c03a015d9534579061c1785d4 Mon Sep 17 00:00:00 2001 From: shrouxm Date: Wed, 30 Oct 2024 11:27:36 -0700 Subject: [PATCH] feat: implement remaining functionality and do basic tests --- .../apps/graphql/schema/schema.graphql | 9 +- .../soil_id/graphql/soil_data/mutations.py | 1 - .../graphql/soil_data/push_mutation.py | 83 ++++++-- .../apps/soil_id/graphql/soil_data/types.py | 1 + .../tests/graphql/mutations/test_soil_data.py | 201 ++++++++++++++++-- 5 files changed, 244 insertions(+), 51 deletions(-) diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index 0a0a3155d..eeee271cf 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -2221,7 +2221,7 @@ type SoilDataUpdateMutationPayload { } input SoilDataUpdateMutationInput { - siteId: ID! + depthIntervalPreset: SoilIdSoilDataDepthIntervalPresetChoices downSlope: SoilIdSoilDataDownSlopeChoices crossSlope: SoilIdSoilDataCrossSlopeChoices bedrock: Int @@ -2239,7 +2239,7 @@ input SoilDataUpdateMutationInput { soilDepthSelect: SoilIdSoilDataSoilDepthSelectChoices landCoverSelect: SoilIdSoilDataLandCoverSelectChoices grazingSelect: SoilIdSoilDataGrazingSelectChoices - depthIntervalPreset: SoilIdSoilDataDepthIntervalPresetChoices + siteId: ID! clientMutationId: String } @@ -2250,7 +2250,6 @@ type DepthDependentSoilDataUpdateMutationPayload { } input DepthDependentSoilDataUpdateMutationInput { - siteId: ID! depthInterval: DepthIntervalInput! texture: SoilIdDepthDependentSoilDataTextureChoices clayPercent: Int @@ -2274,6 +2273,7 @@ input DepthDependentSoilDataUpdateMutationInput { soilOrganicMatterTesting: SoilIdDepthDependentSoilDataSoilOrganicMatterTestingChoices sodiumAbsorptionRatio: Decimal carbonates: SoilIdDepthDependentSoilDataCarbonatesChoices + siteId: ID! clientMutationId: String } @@ -2315,6 +2315,7 @@ input SoilDataPushInputEntry { } input SoilDataPushInputSoilData { + depthIntervalPreset: SoilIdSoilDataDepthIntervalPresetChoices downSlope: SoilIdSoilDataDownSlopeChoices crossSlope: SoilIdSoilDataCrossSlopeChoices bedrock: Int @@ -2383,7 +2384,6 @@ type SoilDataUpdateDepthIntervalMutationPayload { } input SoilDataUpdateDepthIntervalMutationInput { - siteId: ID! label: String depthInterval: DepthIntervalInput! soilTextureEnabled: Boolean @@ -2394,6 +2394,7 @@ input SoilDataUpdateDepthIntervalMutationInput { electricalConductivityEnabled: Boolean sodiumAdsorptionRatioEnabled: Boolean soilStructureEnabled: Boolean + siteId: ID! applyToIntervals: [DepthIntervalInput!] = null clientMutationId: String } diff --git a/terraso_backend/apps/soil_id/graphql/soil_data/mutations.py b/terraso_backend/apps/soil_id/graphql/soil_data/mutations.py index c88770881..bae8f5775 100644 --- a/terraso_backend/apps/soil_id/graphql/soil_data/mutations.py +++ b/terraso_backend/apps/soil_id/graphql/soil_data/mutations.py @@ -131,7 +131,6 @@ class SoilDataUpdateMutation(BaseWriteMutation): class Input(SoilDataInputs): site_id = graphene.ID(required=True) - depth_interval_preset = SoilDataNode.depth_interval_preset_enum() @classmethod def mutate_and_get_payload(cls, root, info, site_id, **kwargs): diff --git a/terraso_backend/apps/soil_id/graphql/soil_data/push_mutation.py b/terraso_backend/apps/soil_id/graphql/soil_data/push_mutation.py index f34f59df2..9eb3b54ba 100644 --- a/terraso_backend/apps/soil_id/graphql/soil_data/push_mutation.py +++ b/terraso_backend/apps/soil_id/graphql/soil_data/push_mutation.py @@ -82,13 +82,12 @@ class Input: ) @staticmethod - def record_update(user: User, soil_data_entries: list[dict]) -> list[SoilDataHistory]: + def record_soil_data_push(user: User, soil_data_entries: list[dict]) -> list[SoilDataHistory]: history_entries = [] for entry in soil_data_entries: - changes = copy.deepcopy(entry) - site_id = changes.pop("site_id") - site = Site.objects.filter(id=site_id).first() + changes = copy.deepcopy(entry["soil_data"]) + site = Site.objects.filter(id=entry["site_id"]).first() history_entry = SoilDataHistory(site=site, changed_by=user, soil_data_changes=changes) history_entry.save() @@ -97,10 +96,10 @@ def record_update(user: User, soil_data_entries: list[dict]) -> list[SoilDataHis return history_entries @staticmethod - def record_update_failure( + def record_entry_failure( history_entry: SoilDataHistory, reason: SoilDataPushFailureReason, site_id: str ): - history_entry.update_failure_reason = reason + history_entry.update_failure_reason = reason.value history_entry.save() return SoilDataPushEntry(site_id=site_id, result=SoilDataPushEntryFailure(reason=reason)) @@ -114,11 +113,59 @@ def get_valid_site_for_soil_update(user: User, site_id: str): if not check_site_permission(user, SiteAction.ENTER_DATA, Context(site=site)): return None, SoilDataPushFailureReason.NOT_ALLOWED + if not check_site_permission(user, SiteAction.UPDATE_DEPTH_INTERVAL, Context(site=site)): + return None, SoilDataPushFailureReason.NOT_ALLOWED + if not hasattr(site, "soil_data"): site.soil_data = SoilData() return site, None + @staticmethod + def save_soil_data(site: Site, soil_data: dict): + if ( + "depth_interval_preset" in soil_data + and soil_data["depth_interval_preset"] != site.soil_data.depth_interval_preset + ): + site.soil_data.depth_intervals.all().delete() + + BaseWriteMutation.assign_graphql_fields_to_model_instance( + model_instance=site.soil_data, fields=soil_data + ) + + @staticmethod + def save_depth_dependent_data(site: Site, depth_dependent_data: list[dict]): + for depth_dependent_entry in depth_dependent_data: + interval = depth_dependent_entry.pop("depth_interval") + depth_interval, _ = site.soil_data.depth_dependent_data.get_or_create( + depth_interval_start=interval["start"], + depth_interval_end=interval["end"], + ) + + BaseWriteMutation.assign_graphql_fields_to_model_instance( + model_instance=depth_interval, fields=depth_dependent_entry + ) + + @staticmethod + def update_depth_intervals(site: Site, depth_intervals: list[dict]): + for depth_interval_input in depth_intervals: + interval_input = depth_interval_input.pop("depth_interval") + depth_interval, _ = site.soil_data.depth_intervals.get_or_create( + depth_interval_start=interval_input["start"], + depth_interval_end=interval_input["end"], + ) + + BaseWriteMutation.assign_graphql_fields_to_model_instance( + model_instance=depth_interval, fields=depth_interval_input + ) + + @staticmethod + def delete_depth_intervals(site: Site, deleted_depth_intervals: list[dict]): + for interval in deleted_depth_intervals: + site.soil_data.depth_intervals.filter( + depth_interval_start=interval["start"], depth_interval_end=interval["end"] + ).delete() + @staticmethod def get_entry_result(user: User, soil_data_entry: dict, history_entry: SoilDataHistory): site_id = soil_data_entry["site_id"] @@ -131,33 +178,23 @@ def get_entry_result(user: User, soil_data_entry: dict, history_entry: SoilDataH try: site, reason = SoilDataPush.get_valid_site_for_soil_update(user=user, site_id=site_id) if site is None: - return SoilDataPush.record_update_failure( + return SoilDataPush.record_entry_failure( history_entry=history_entry, site_id=site_id, reason=reason, ) - BaseWriteMutation.assign_graphql_fields_to_model_instance( - model_instance=site.soil_data, fields=soil_data - ) - - for depth_dependent_entry in depth_dependent_data: - interval = depth_dependent_entry.pop("depth_interval") - depth_interval, _ = site.soil_data.depth_dependent_data.get_or_create( - depth_interval_start=interval["start"], - depth_interval_end=interval["end"], - ) - - BaseWriteMutation.assign_graphql_fields_to_model_instance( - model_instance=depth_interval, fields=depth_dependent_entry - ) + SoilDataPush.save_soil_data(site, soil_data) + SoilDataPush.update_depth_intervals(site, depth_intervals) + SoilDataPush.save_depth_dependent_data(site, depth_dependent_data) + SoilDataPush.delete_depth_intervals(site, deleted_depth_intervals) history_entry.update_succeeded = True history_entry.save() return SoilDataPushEntry(site_id=site_id, result=SoilDataPushEntrySuccess(site=site)) except (ValidationError, IntegrityError): - return SoilDataPush.record_update_failure( + return SoilDataPush.record_entry_failure( history_entry=history_entry, site_id=site_id, reason=SoilDataPushFailureReason.INVALID_DATA, @@ -169,7 +206,7 @@ def mutate_and_get_payload(cls, root, info, soil_data_entries: list[dict]): user = info.context.user with transaction.atomic(): - history_entries = SoilDataPush.record_update(user, soil_data_entries) + history_entries = SoilDataPush.record_soil_data_push(user, soil_data_entries) with transaction.atomic(): for entry, history_entry in zip(soil_data_entries, history_entries): diff --git a/terraso_backend/apps/soil_id/graphql/soil_data/types.py b/terraso_backend/apps/soil_id/graphql/soil_data/types.py index 5428561d8..19a140898 100644 --- a/terraso_backend/apps/soil_id/graphql/soil_data/types.py +++ b/terraso_backend/apps/soil_id/graphql/soil_data/types.py @@ -37,6 +37,7 @@ class SoilDataDepthIntervalInputs: class SoilDataInputs: + depth_interval_preset = SoilDataNode.depth_interval_preset_enum() down_slope = SoilDataNode.down_slope_enum() cross_slope = SoilDataNode.cross_slope_enum() bedrock = graphene.Int() diff --git a/terraso_backend/tests/graphql/mutations/test_soil_data.py b/terraso_backend/tests/graphql/mutations/test_soil_data.py index fb588c3d5..81cf7b518 100644 --- a/terraso_backend/tests/graphql/mutations/test_soil_data.py +++ b/terraso_backend/tests/graphql/mutations/test_soil_data.py @@ -796,13 +796,66 @@ def test_apply_to_all(client, project_site, project_manager): ... on SoilDataPushEntrySuccess { site { soilData { + downSlope + crossSlope + bedrock + depthIntervalPreset + slopeLandscapePosition slopeAspect + slopeSteepnessSelect + slopeSteepnessPercent + slopeSteepnessDegree + surfaceCracksSelect + surfaceSaltSelect + floodingSelect + limeRequirementsSelect + surfaceStoninessSelect + waterTableDepthSelect + soilDepthSelect + landCoverSelect + grazingSelect depthDependentData { depthInterval { start end } + texture + rockFragmentVolume clayPercent + colorHue + colorValue + colorChroma + colorPhotoUsed + colorPhotoSoilCondition + colorPhotoLightingCondition + conductivity + conductivityTest + conductivityUnit + structure + ph + phTestingSolution + phTestingMethod + soilOrganicCarbon + soilOrganicMatter + soilOrganicCarbonTesting + soilOrganicMatterTesting + sodiumAbsorptionRatio + carbonates + } + depthIntervals { + label + depthInterval { + start + end + } + soilTextureEnabled + soilColorEnabled + carbonatesEnabled + phEnabled + soilOrganicCarbonMatterEnabled + electricalConductivityEnabled + sodiumAdsorptionRatioEnabled + soilStructureEnabled } } } @@ -815,16 +868,19 @@ def test_apply_to_all(client, project_site, project_manager): """ -def test_push_soil_data(client, user): - sites = mixer.cycle(2).blend(Site, owner=user) +def test_push_soil_data_can_process_mixed_results(client, user): + non_user = mixer.blend(User) + user_sites = mixer.cycle(2).blend(Site, owner=user) + non_user_site = mixer.blend(Site, owner=non_user) client.force_login(user) response = graphql_query( PUSH_SOIL_DATA_QUERY, input_data={ "soilDataEntries": [ + # update data successfully { - "siteId": str(sites[0].id), + "siteId": str(user_sites[0].id), "soilData": { "slopeAspect": 10, "depthDependentData": [], @@ -832,19 +888,31 @@ def test_push_soil_data(client, user): "deletedDepthIntervals": [], }, }, + # constraint violations { - "siteId": str(sites[1].id), + "siteId": str(user_sites[1].id), "soilData": { - "depthDependentData": [ - {"depthInterval": {"start": 0, "end": 10}, "clayPercent": 5} - ], + "slopeAspect": -1, + "depthDependentData": [], + "depthIntervals": [], + "deletedDepthIntervals": [], + }, + }, + # no permission + { + "siteId": str(non_user_site.id), + "soilData": { + "slopeAspect": 5, + "depthDependentData": [], "depthIntervals": [], "deletedDepthIntervals": [], }, }, + # does not exist { - "siteId": str("c9df7deb-6b9d-4c55-8ba6-641acc47dbb2"), + "siteId": "00000000-0000-0000-0000-000000000000", "soilData": { + "slopeAspect": 15, "depthDependentData": [], "depthIntervals": [], "deletedDepthIntervals": [], @@ -855,26 +923,113 @@ def test_push_soil_data(client, user): client=client, ) - print(response.json()) + assert response.json() result = response.json()["data"]["pushSoilData"] assert result["errors"] is None - assert result["results"][2]["result"]["reason"] == "DOES_NOT_EXIST" + assert result["results"][0]["result"]["site"]["soilData"]["slopeAspect"] == 10 + assert result["results"][1]["result"]["reason"] == "INVALID_DATA" + assert result["results"][2]["result"]["reason"] == "NOT_ALLOWED" + assert result["results"][3]["result"]["reason"] == "DOES_NOT_EXIST" - assert response.json() + user_sites[0].refresh_from_db() + assert user_sites[0].soil_data.slope_aspect == 10 - sites[0].refresh_from_db() - sites[1].refresh_from_db() + user_sites[1].refresh_from_db() + assert not hasattr(user_sites[1], "soil_data") - assert sites[0].soil_data.slope_aspect == 10 - assert ( - sites[1] - .soil_data.depth_dependent_data.get(depth_interval_start=0, depth_interval_end=10) - .clay_percent - == 5 + non_user_site.refresh_from_db() + assert not hasattr(non_user_site, "soil_data") + + history_0 = SoilDataHistory.objects.get(site=user_sites[0]) + assert history_0.update_failure_reason is None + assert history_0.update_succeeded + assert history_0.soil_data_changes["slope_aspect"] == 10 + + history_1 = SoilDataHistory.objects.get(site=user_sites[1]) + assert history_1.update_failure_reason == "INVALID_DATA" + assert not history_1.update_succeeded + assert history_1.soil_data_changes["slope_aspect"] == -1 + + history_2 = SoilDataHistory.objects.get(site=non_user_site) + assert history_2.update_failure_reason == "NOT_ALLOWED" + assert not history_2.update_succeeded + assert history_2.soil_data_changes["slope_aspect"] == 5 + + history_3 = SoilDataHistory.objects.get(site=None) + assert history_3.update_failure_reason == "DOES_NOT_EXIST" + assert not history_3.update_succeeded + assert history_3.soil_data_changes["slope_aspect"] == 15 + + +# TODO: fleshly out +def test_push_soil_data_success(client, user): + site = mixer.blend(Site, owner=user) + + site.soil_data = SoilData() + site.soil_data.save() + site.soil_data.depth_intervals.get_or_create(depth_interval_start=10, depth_interval_end=20) + + soil_data_changes = { + "slopeAspect": 10, + "depthDependentData": [{"depthInterval": {"start": 0, "end": 10}, "clayPercent": 10}], + "depthIntervals": [ + { + "depthInterval": {"start": 0, "end": 10}, + "soilTextureEnabled": True, + } + ], + "deletedDepthIntervals": [ + { + "start": 10, + "end": 20, + } + ], + } + + client.force_login(user) + response = graphql_query( + PUSH_SOIL_DATA_QUERY, + input_data={ + "soilDataEntries": [ + {"siteId": str(site.id), "soilData": soil_data_changes}, + ] + }, + client=client, ) - history_1 = SoilDataHistory.objects.get(site=sites[0]) - assert history_1.soil_data_changes["soil_data"]["slope_aspect"] == 10 + assert response.json() + result = response.json()["data"]["pushSoilData"] + assert result["errors"] is None + assert result["results"][0]["result"]["site"]["soilData"]["slopeAspect"] == 10 + + site.refresh_from_db() - history_2 = SoilDataHistory.objects.get(site=sites[1]) - assert history_2.soil_data_changes["soil_data"]["depth_dependent_data"][0]["clay_percent"] == 5 + assert site.soil_data.slope_aspect == 10 + assert ( + site.soil_data.depth_dependent_data.get( + depth_interval_start=0, depth_interval_end=10 + ).clay_percent + == 10 + ) + assert ( + site.soil_data.depth_intervals.get( + depth_interval_start=0, depth_interval_end=10 + ).soil_texture_enabled + == True + ) + assert not site.soil_data.depth_intervals.filter( + depth_interval_start=10, depth_interval_end=20 + ).exists() + + history = SoilDataHistory.objects.get(site=site) + assert history.update_failure_reason is None + assert history.update_succeeded + assert history.soil_data_changes["slope_aspect"] == 10 + assert history.soil_data_changes["depth_dependent_data"][0]["depth_interval"]["start"] == 0 + assert history.soil_data_changes["depth_dependent_data"][0]["depth_interval"]["end"] == 10 + assert history.soil_data_changes["depth_dependent_data"][0]["clay_percent"] == 10 + assert history.soil_data_changes["depth_intervals"][0]["depth_interval"]["start"] == 0 + assert history.soil_data_changes["depth_intervals"][0]["depth_interval"]["end"] == 10 + assert history.soil_data_changes["depth_intervals"][0]["soil_texture_enabled"] == True + assert history.soil_data_changes["deleted_depth_intervals"][0]["start"] == 10 + assert history.soil_data_changes["deleted_depth_intervals"][0]["end"] == 20