From 9d0e44d6094afe21bfdf9f4dfeecf56d64ed3e0b Mon Sep 17 00:00:00 2001 From: shrouxm Date: Tue, 29 Oct 2024 17:47:29 -0700 Subject: [PATCH 1/9] refactor: make some model-saving logic re-usable --- .../apps/graphql/schema/commons.py | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/terraso_backend/apps/graphql/schema/commons.py b/terraso_backend/apps/graphql/schema/commons.py index f518e2a98..6f4559975 100644 --- a/terraso_backend/apps/graphql/schema/commons.py +++ b/terraso_backend/apps/graphql/schema/commons.py @@ -15,11 +15,11 @@ import enum import json -from typing import Optional +from typing import Any, Optional import structlog from django.core.exceptions import NON_FIELD_ERRORS, ValidationError -from django.db import IntegrityError +from django.db import IntegrityError, models from graphene import Connection, Int, relay from graphene.types.generic import GenericScalar from graphql import get_nullable_type @@ -178,7 +178,7 @@ def not_found(cls, model=None, field=None, msg=None): class BaseWriteMutation(BaseAuthenticatedMutation): - skip_field_validation: Optional[str] = None + skip_field_validation: Optional[list[str]] = None @classmethod def mutate_and_get_payload(cls, root, info, **kwargs): @@ -202,16 +202,12 @@ def mutate_and_get_payload(cls, root, info, **kwargs): result_class = cls.result_class or cls.model_class result_instance = kwargs.pop("result_instance", model_instance) - for attr, value in kwargs.items(): - if isinstance(value, enum.Enum): - value = value.value - setattr(model_instance, attr, value) - try: - kwargs = {} - if cls.skip_field_validation is not None: - kwargs["exclude"] = cls.skip_field_validation - model_instance.full_clean(**kwargs) + BaseWriteMutation.assign_graphql_fields_to_model_instance( + model_instance=model_instance, + fields=kwargs, + skip_field_validation=cls.skip_field_validation, + ) except ValidationError as exc: logger.info( "Attempt to mutate an model, but it's invalid", @@ -220,9 +216,6 @@ def mutate_and_get_payload(cls, root, info, **kwargs): raise GraphQLValidationException.from_validation_error( exc, model_name=cls.model_class.__name__ ) - - try: - model_instance.save() except IntegrityError as exc: logger.info( "Attempt to mutate an model, but it's not unique", @@ -251,6 +244,23 @@ def mutate_and_get_payload(cls, root, info, **kwargs): def is_update(cls, data): return "id" in data + @staticmethod + def assign_graphql_fields_to_model_instance( + model_instance: models.Model, + fields: dict[str, Any], + skip_field_validation: Optional[list[str]] = None, + ): + for attr, value in fields.items(): + if isinstance(value, enum.Enum): + value = value.value + setattr(model_instance, attr, value) + + clean_args = {} + if skip_field_validation is not None: + clean_args["exclude"] = skip_field_validation + model_instance.full_clean(**clean_args) + model_instance.save() + @staticmethod def remove_null_fields(kwargs, options=[str]): """It seems like for some fields, if the frontend does not pass an argument, the From f36d2da02d4315822969adf290da734024fb02f8 Mon Sep 17 00:00:00 2001 From: shrouxm Date: Tue, 29 Oct 2024 17:47:57 -0700 Subject: [PATCH 2/9] feat: add soil_data_history table --- .../migrations/0019_soildatahistory.py | 73 +++++++++++++++++++ .../apps/soil_id/models/__init__.py | 2 + .../apps/soil_id/models/soil_data_history.py | 52 +++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 terraso_backend/apps/soil_id/migrations/0019_soildatahistory.py create mode 100644 terraso_backend/apps/soil_id/models/soil_data_history.py diff --git a/terraso_backend/apps/soil_id/migrations/0019_soildatahistory.py b/terraso_backend/apps/soil_id/migrations/0019_soildatahistory.py new file mode 100644 index 000000000..1bc245dbb --- /dev/null +++ b/terraso_backend/apps/soil_id/migrations/0019_soildatahistory.py @@ -0,0 +1,73 @@ +# Copyright © 2024 Technology Matters +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see https://www.gnu.org/licenses/. + +# Generated by Django 5.1.1 on 2024-10-01 20:04 + +import uuid + +import django.db.models.deletion +import rules.contrib.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("project_management", "0028_site_elevation"), + ("soil_id", "0018_alter_projectsoilsettings_options_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SoilDataHistory", + fields=[ + ("deleted_at", models.DateTimeField(db_index=True, editable=False, null=True)), + ("deleted_by_cascade", models.BooleanField(default=False, editable=False)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("soil_data_changes", models.JSONField()), + ( + "changed_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ( + "site", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="project_management.site", + ), + ), + ("update_failure_reason", models.TextField(null=True)), + ("update_succeeded", models.BooleanField(default=False)), + ], + options={ + "ordering": ["created_at"], + "get_latest_by": "-created_at", + "abstract": False, + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/terraso_backend/apps/soil_id/models/__init__.py b/terraso_backend/apps/soil_id/models/__init__.py index 982b16188..3dd845e32 100644 --- a/terraso_backend/apps/soil_id/models/__init__.py +++ b/terraso_backend/apps/soil_id/models/__init__.py @@ -23,6 +23,7 @@ ProjectSoilSettings, ) from .soil_data import SoilData, SoilDataDepthInterval +from .soil_data_history import SoilDataHistory from .soil_id_cache import SoilIdCache __all__ = [ @@ -35,4 +36,5 @@ "BLMIntervalDefaults", "DepthIntervalPreset", "SoilIdCache", + "SoilDataHistory", ] diff --git a/terraso_backend/apps/soil_id/models/soil_data_history.py b/terraso_backend/apps/soil_id/models/soil_data_history.py new file mode 100644 index 000000000..cf42981bd --- /dev/null +++ b/terraso_backend/apps/soil_id/models/soil_data_history.py @@ -0,0 +1,52 @@ +# Copyright © 2024 Technology Matters +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see https://www.gnu.org/licenses/. + +from django.db import models + +from apps.core.models import User +from apps.core.models.commons import BaseModel +from apps.project_management.models.sites import Site + + +class SoilDataHistory(BaseModel): + site = models.ForeignKey(Site, null=True, on_delete=models.CASCADE) + changed_by = models.ForeignKey(User, on_delete=models.CASCADE) + update_succeeded = models.BooleanField(null=False, blank=False, default=False) + update_failure_reason = models.TextField(null=True) + + # intended JSON schema: { + # ...soilDataInputs, + # "depth_dependent_data": [{ + # "depth_interval": { + # "start": number, + # "end": number + # }, + # ...depthDependentInputs + # }], + # "depth_intervals": [{ + # "depth_interval": { + # "start": number, + # "end": number + # }, + # ...depthIntervalConfig + # }], + # "deleted_depth_intervals": [{ + # "depth_interval": { + # "start": number, + # "end": number + # } + # }] + # } + soil_data_changes = models.JSONField() From d70a96dd63c7fd855ed64b30acde0e328e52f9a9 Mon Sep 17 00:00:00 2001 From: shrouxm Date: Tue, 29 Oct 2024 17:49:53 -0700 Subject: [PATCH 3/9] refactor: make some mutation fields re-usable --- .../soil_id/graphql/soil_data/mutations.py | 64 ++------------ .../apps/soil_id/graphql/soil_data/types.py | 84 +++++++++++++++++++ 2 files changed, 92 insertions(+), 56 deletions(-) create mode 100644 terraso_backend/apps/soil_id/graphql/soil_data/types.py 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 ec0dd7421..c88770881 100644 --- a/terraso_backend/apps/soil_id/graphql/soil_data/mutations.py +++ b/terraso_backend/apps/soil_id/graphql/soil_data/mutations.py @@ -22,10 +22,14 @@ from apps.project_management.permission_rules import Context from apps.project_management.permission_table import SiteAction, check_site_permission from apps.soil_id.graphql.soil_data.queries import ( - DepthDependentSoilDataNode, SoilDataDepthIntervalNode, SoilDataNode, ) +from apps.soil_id.graphql.soil_data.types import ( + SoilDataDepthDependentInputs, + SoilDataDepthIntervalInputs, + SoilDataInputs, +) from apps.soil_id.graphql.types import DepthIntervalInput from apps.soil_id.models.depth_dependent_soil_data import DepthDependentSoilData from apps.soil_id.models.soil_data import SoilData, SoilDataDepthInterval @@ -36,18 +40,8 @@ class SoilDataUpdateDepthIntervalMutation(BaseWriteMutation): model_class = SoilDataDepthIntervalNode result_class = SoilData - class Input: + class Input(SoilDataDepthIntervalInputs): site_id = graphene.ID(required=True) - label = graphene.String() - depth_interval = graphene.Field(DepthIntervalInput, required=True) - soil_texture_enabled = graphene.Boolean() - soil_color_enabled = graphene.Boolean() - carbonates_enabled = graphene.Boolean() - ph_enabled = graphene.Boolean() - soil_organic_carbon_matter_enabled = graphene.Boolean() - electrical_conductivity_enabled = graphene.Boolean() - sodium_adsorption_ratio_enabled = graphene.Boolean() - soil_structure_enabled = graphene.Boolean() apply_to_intervals = graphene.Field(graphene.List(graphene.NonNull(DepthIntervalInput))) @classmethod @@ -135,25 +129,8 @@ class SoilDataUpdateMutation(BaseWriteMutation): soil_data = graphene.Field(SoilDataNode) model_class = SoilData - class Input: + class Input(SoilDataInputs): site_id = graphene.ID(required=True) - down_slope = SoilDataNode.down_slope_enum() - cross_slope = SoilDataNode.cross_slope_enum() - bedrock = graphene.Int() - slope_landscape_position = SoilDataNode.slope_landscape_position_enum() - slope_aspect = graphene.Int() - slope_steepness_select = SoilDataNode.slope_steepness_enum() - slope_steepness_percent = graphene.Int() - slope_steepness_degree = graphene.Int() - surface_cracks_select = SoilDataNode.surface_cracks_enum() - surface_salt_select = SoilDataNode.surface_salt_enum() - flooding_select = SoilDataNode.flooding_enum() - lime_requirements_select = SoilDataNode.lime_requirements_enum() - surface_stoniness_select = SoilDataNode.surface_stoniness_enum() - water_table_depth_select = SoilDataNode.water_table_depth_enum() - soil_depth_select = SoilDataNode.soil_depth_enum() - land_cover_select = SoilDataNode.land_cover_enum() - grazing_select = SoilDataNode.grazing_enum() depth_interval_preset = SoilDataNode.depth_interval_preset_enum() @classmethod @@ -184,33 +161,8 @@ class DepthDependentSoilDataUpdateMutation(BaseWriteMutation): model_class = DepthDependentSoilData result_class = SoilData - class Input: + class Input(SoilDataDepthDependentInputs): site_id = graphene.ID(required=True) - depth_interval = graphene.Field(DepthIntervalInput, required=True) - texture = DepthDependentSoilDataNode.texture_enum() - clay_percent = graphene.Int() - rock_fragment_volume = DepthDependentSoilDataNode.rock_fragment_volume_enum() - color_hue = graphene.Float() - color_value = graphene.Float() - color_chroma = graphene.Float() - color_photo_used = graphene.Boolean() - color_photo_soil_condition = DepthDependentSoilDataNode.color_photo_soil_condition_enum() - color_photo_lighting_condition = ( - DepthDependentSoilDataNode.color_photo_lighting_condition_enum() - ) - conductivity = graphene.Decimal() - conductivity_test = DepthDependentSoilDataNode.conductivity_test_enum() - conductivity_unit = DepthDependentSoilDataNode.conductivity_unit_enum() - structure = DepthDependentSoilDataNode.structure_enum() - ph = graphene.Decimal() - ph_testing_solution = DepthDependentSoilDataNode.ph_testing_solution_enum() - ph_testing_method = DepthDependentSoilDataNode.ph_testing_method_enum() - soil_organic_carbon = graphene.Decimal() - soil_organic_matter = graphene.Decimal() - soil_organic_carbon_testing = DepthDependentSoilDataNode.soil_organic_carbon_testing_enum() - soil_organic_matter_testing = DepthDependentSoilDataNode.soil_organic_matter_testing_enum() - sodium_absorption_ratio = graphene.Decimal() - carbonates = DepthDependentSoilDataNode.carbonates_enum() @classmethod def mutate_and_get_payload(cls, root, info, site_id, depth_interval, **kwargs): diff --git a/terraso_backend/apps/soil_id/graphql/soil_data/types.py b/terraso_backend/apps/soil_id/graphql/soil_data/types.py new file mode 100644 index 000000000..5428561d8 --- /dev/null +++ b/terraso_backend/apps/soil_id/graphql/soil_data/types.py @@ -0,0 +1,84 @@ +# Copyright © 2024 Technology Matters +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see https://www.gnu.org/licenses/. + + +import graphene + +from apps.soil_id.graphql.soil_data.queries import ( + DepthDependentSoilDataNode, + SoilDataNode, +) +from apps.soil_id.graphql.types import DepthIntervalInput + + +class SoilDataDepthIntervalInputs: + label = graphene.String() + depth_interval = graphene.Field(DepthIntervalInput, required=True) + soil_texture_enabled = graphene.Boolean() + soil_color_enabled = graphene.Boolean() + carbonates_enabled = graphene.Boolean() + ph_enabled = graphene.Boolean() + soil_organic_carbon_matter_enabled = graphene.Boolean() + electrical_conductivity_enabled = graphene.Boolean() + sodium_adsorption_ratio_enabled = graphene.Boolean() + soil_structure_enabled = graphene.Boolean() + + +class SoilDataInputs: + down_slope = SoilDataNode.down_slope_enum() + cross_slope = SoilDataNode.cross_slope_enum() + bedrock = graphene.Int() + slope_landscape_position = SoilDataNode.slope_landscape_position_enum() + slope_aspect = graphene.Int() + slope_steepness_select = SoilDataNode.slope_steepness_enum() + slope_steepness_percent = graphene.Int() + slope_steepness_degree = graphene.Int() + surface_cracks_select = SoilDataNode.surface_cracks_enum() + surface_salt_select = SoilDataNode.surface_salt_enum() + flooding_select = SoilDataNode.flooding_enum() + lime_requirements_select = SoilDataNode.lime_requirements_enum() + surface_stoniness_select = SoilDataNode.surface_stoniness_enum() + water_table_depth_select = SoilDataNode.water_table_depth_enum() + soil_depth_select = SoilDataNode.soil_depth_enum() + land_cover_select = SoilDataNode.land_cover_enum() + grazing_select = SoilDataNode.grazing_enum() + + +class SoilDataDepthDependentInputs: + depth_interval = graphene.Field(DepthIntervalInput, required=True) + texture = DepthDependentSoilDataNode.texture_enum() + clay_percent = graphene.Int() + rock_fragment_volume = DepthDependentSoilDataNode.rock_fragment_volume_enum() + color_hue = graphene.Float() + color_value = graphene.Float() + color_chroma = graphene.Float() + color_photo_used = graphene.Boolean() + color_photo_soil_condition = DepthDependentSoilDataNode.color_photo_soil_condition_enum() + color_photo_lighting_condition = ( + DepthDependentSoilDataNode.color_photo_lighting_condition_enum() + ) + conductivity = graphene.Decimal() + conductivity_test = DepthDependentSoilDataNode.conductivity_test_enum() + conductivity_unit = DepthDependentSoilDataNode.conductivity_unit_enum() + structure = DepthDependentSoilDataNode.structure_enum() + ph = graphene.Decimal() + ph_testing_solution = DepthDependentSoilDataNode.ph_testing_solution_enum() + ph_testing_method = DepthDependentSoilDataNode.ph_testing_method_enum() + soil_organic_carbon = graphene.Decimal() + soil_organic_matter = graphene.Decimal() + soil_organic_carbon_testing = DepthDependentSoilDataNode.soil_organic_carbon_testing_enum() + soil_organic_matter_testing = DepthDependentSoilDataNode.soil_organic_matter_testing_enum() + sodium_absorption_ratio = graphene.Decimal() + carbonates = DepthDependentSoilDataNode.carbonates_enum() From b1d68d61b50230e13952167d6c0a19a5935dee9f Mon Sep 17 00:00:00 2001 From: shrouxm Date: Tue, 29 Oct 2024 17:50:14 -0700 Subject: [PATCH 4/9] feat: add push mutation --- .../apps/graphql/schema/schema.graphql | 100 ++++++++++ terraso_backend/apps/graphql/schema/schema.py | 2 + .../graphql/soil_data/push_mutation.py | 182 ++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 terraso_backend/apps/soil_id/graphql/soil_data/push_mutation.py diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index c3d5a5ea2..0a0a3155d 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -1644,6 +1644,7 @@ type Mutations { markProjectSeen(input: ProjectMarkSeenMutationInput!): ProjectMarkSeenMutationPayload! updateSoilData(input: SoilDataUpdateMutationInput!): SoilDataUpdateMutationPayload! updateDepthDependentSoilData(input: DepthDependentSoilDataUpdateMutationInput!): DepthDependentSoilDataUpdateMutationPayload! + pushSoilData(input: SoilDataPushInput!): SoilDataPushPayload! updateSoilDataDepthInterval(input: SoilDataUpdateDepthIntervalMutationInput!): SoilDataUpdateDepthIntervalMutationPayload! deleteSoilDataDepthInterval(input: SoilDataDeleteDepthIntervalMutationInput!): SoilDataDeleteDepthIntervalMutationPayload! updateProjectSoilSettings(input: ProjectSoilSettingsUpdateMutationInput!): ProjectSoilSettingsUpdateMutationPayload! @@ -2276,6 +2277,105 @@ input DepthDependentSoilDataUpdateMutationInput { clientMutationId: String } +type SoilDataPushPayload { + errors: GenericScalar + results: [SoilDataPushEntry!]! + clientMutationId: String +} + +type SoilDataPushEntry { + siteId: ID! + result: SoilDataPushEntryResult! +} + +union SoilDataPushEntryResult = SoilDataPushEntrySuccess | SoilDataPushEntryFailure + +type SoilDataPushEntrySuccess { + site: SiteNode! +} + +type SoilDataPushEntryFailure { + reason: SoilDataPushFailureReason! +} + +enum SoilDataPushFailureReason { + DOES_NOT_EXIST + NOT_ALLOWED + INVALID_DATA +} + +input SoilDataPushInput { + soilDataEntries: [SoilDataPushInputEntry!]! + clientMutationId: String +} + +input SoilDataPushInputEntry { + siteId: ID! + soilData: SoilDataPushInputSoilData! +} + +input SoilDataPushInputSoilData { + downSlope: SoilIdSoilDataDownSlopeChoices + crossSlope: SoilIdSoilDataCrossSlopeChoices + bedrock: Int + slopeLandscapePosition: SoilIdSoilDataSlopeLandscapePositionChoices + slopeAspect: Int + slopeSteepnessSelect: SoilIdSoilDataSlopeSteepnessSelectChoices + slopeSteepnessPercent: Int + slopeSteepnessDegree: Int + surfaceCracksSelect: SoilIdSoilDataSurfaceCracksSelectChoices + surfaceSaltSelect: SoilIdSoilDataSurfaceSaltSelectChoices + floodingSelect: SoilIdSoilDataFloodingSelectChoices + limeRequirementsSelect: SoilIdSoilDataLimeRequirementsSelectChoices + surfaceStoninessSelect: SoilIdSoilDataSurfaceStoninessSelectChoices + waterTableDepthSelect: SoilIdSoilDataWaterTableDepthSelectChoices + soilDepthSelect: SoilIdSoilDataSoilDepthSelectChoices + landCoverSelect: SoilIdSoilDataLandCoverSelectChoices + grazingSelect: SoilIdSoilDataGrazingSelectChoices + depthDependentData: [SoilDataPushInputDepthDependentData!]! + depthIntervals: [SoilDataPushInputDepthInterval!]! + deletedDepthIntervals: [DepthIntervalInput!]! +} + +input SoilDataPushInputDepthDependentData { + depthInterval: DepthIntervalInput! + texture: SoilIdDepthDependentSoilDataTextureChoices + clayPercent: Int + rockFragmentVolume: SoilIdDepthDependentSoilDataRockFragmentVolumeChoices + colorHue: Float + colorValue: Float + colorChroma: Float + colorPhotoUsed: Boolean + colorPhotoSoilCondition: SoilIdDepthDependentSoilDataColorPhotoSoilConditionChoices + colorPhotoLightingCondition: SoilIdDepthDependentSoilDataColorPhotoLightingConditionChoices + conductivity: Decimal + conductivityTest: SoilIdDepthDependentSoilDataConductivityTestChoices + conductivityUnit: SoilIdDepthDependentSoilDataConductivityUnitChoices + structure: SoilIdDepthDependentSoilDataStructureChoices + ph: Decimal + phTestingSolution: SoilIdDepthDependentSoilDataPhTestingSolutionChoices + phTestingMethod: SoilIdDepthDependentSoilDataPhTestingMethodChoices + soilOrganicCarbon: Decimal + soilOrganicMatter: Decimal + soilOrganicCarbonTesting: SoilIdDepthDependentSoilDataSoilOrganicCarbonTestingChoices + soilOrganicMatterTesting: SoilIdDepthDependentSoilDataSoilOrganicMatterTestingChoices + sodiumAbsorptionRatio: Decimal + carbonates: SoilIdDepthDependentSoilDataCarbonatesChoices +} + +input SoilDataPushInputDepthInterval { + label: String + depthInterval: DepthIntervalInput! + soilTextureEnabled: Boolean + soilColorEnabled: Boolean + carbonatesEnabled: Boolean + phEnabled: Boolean + soilOrganicCarbonMatterEnabled: Boolean + electricalConductivityEnabled: Boolean + sodiumAdsorptionRatioEnabled: Boolean + soilStructureEnabled: Boolean +} + type SoilDataUpdateDepthIntervalMutationPayload { errors: GenericScalar soilData: SoilDataNode diff --git a/terraso_backend/apps/graphql/schema/schema.py b/terraso_backend/apps/graphql/schema/schema.py index f8525d1c6..850a6b78d 100644 --- a/terraso_backend/apps/graphql/schema/schema.py +++ b/terraso_backend/apps/graphql/schema/schema.py @@ -38,6 +38,7 @@ SoilDataUpdateDepthIntervalMutation, SoilDataUpdateMutation, ) +from apps.soil_id.graphql.soil_data.push_mutation import SoilDataPush from apps.soil_id.graphql.soil_id.queries import soil_id from apps.soil_id.graphql.soil_project.mutations import ( ProjectSoilSettingsDeleteDepthIntervalMutation, @@ -191,6 +192,7 @@ class Mutations(graphene.ObjectType): mark_project_seen = ProjectMarkSeenMutation.Field() update_soil_data = SoilDataUpdateMutation.Field() update_depth_dependent_soil_data = DepthDependentSoilDataUpdateMutation.Field() + push_soil_data = SoilDataPush.Field() update_soil_data_depth_interval = SoilDataUpdateDepthIntervalMutation.Field() delete_soil_data_depth_interval = SoilDataDeleteDepthIntervalMutation.Field() update_project_soil_settings = ProjectSoilSettingsUpdateMutation.Field() 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 new file mode 100644 index 000000000..f34f59df2 --- /dev/null +++ b/terraso_backend/apps/soil_id/graphql/soil_data/push_mutation.py @@ -0,0 +1,182 @@ +import copy + +import graphene +import structlog +from django.db import IntegrityError, transaction +from django.forms import ValidationError + +from apps.core.models.users import User +from apps.graphql.schema.commons import BaseWriteMutation +from apps.graphql.schema.sites import SiteNode +from apps.project_management.models.sites import Site +from apps.project_management.permission_rules import Context +from apps.project_management.permission_table import SiteAction, check_site_permission +from apps.soil_id.graphql.soil_data.types import ( + SoilDataDepthDependentInputs, + SoilDataDepthIntervalInputs, + SoilDataInputs, +) +from apps.soil_id.graphql.types import DepthIntervalInput +from apps.soil_id.models.soil_data import SoilData +from apps.soil_id.models.soil_data_history import SoilDataHistory + +logger = structlog.get_logger(__name__) + + +class SoilDataPushEntrySuccess(graphene.ObjectType): + site = graphene.Field(SiteNode, required=True) + + +# TODO: just a generic "can't access" result? +class SoilDataPushFailureReason(graphene.Enum): + DOES_NOT_EXIST = "DOES_NOT_EXIST" + NOT_ALLOWED = "NOT_ALLOWED" + INVALID_DATA = "INVALID_DATA" + + +class SoilDataPushEntryFailure(graphene.ObjectType): + reason = graphene.Field(SoilDataPushFailureReason, required=True) + + +class SoilDataPushEntryResult(graphene.Union): + class Meta: + types = (SoilDataPushEntrySuccess, SoilDataPushEntryFailure) + + +class SoilDataPushEntry(graphene.ObjectType): + site_id = graphene.ID(required=True) + result = graphene.Field(SoilDataPushEntryResult, required=True) + + +class SoilDataPushInputDepthDependentData(SoilDataDepthDependentInputs, graphene.InputObjectType): + pass + + +class SoilDataPushInputDepthInterval(SoilDataDepthIntervalInputs, graphene.InputObjectType): + pass + + +class SoilDataPushInputSoilData(SoilDataInputs, graphene.InputObjectType): + depth_dependent_data = graphene.Field( + graphene.List(graphene.NonNull(SoilDataPushInputDepthDependentData)), required=True + ) + depth_intervals = graphene.Field( + graphene.List(graphene.NonNull(SoilDataPushInputDepthInterval)), required=True + ) + deleted_depth_intervals = graphene.Field( + graphene.List(graphene.NonNull(DepthIntervalInput)), required=True + ) + + +class SoilDataPushInputEntry(graphene.InputObjectType): + site_id = graphene.ID(required=True) + soil_data = graphene.Field(graphene.NonNull(SoilDataPushInputSoilData)) + + +class SoilDataPush(BaseWriteMutation): + results = graphene.Field(graphene.List(graphene.NonNull(SoilDataPushEntry)), required=True) + + class Input: + soil_data_entries = graphene.Field( + graphene.List(graphene.NonNull(SoilDataPushInputEntry)), required=True + ) + + @staticmethod + def record_update(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() + + history_entry = SoilDataHistory(site=site, changed_by=user, soil_data_changes=changes) + history_entry.save() + history_entries.append(history_entry) + + return history_entries + + @staticmethod + def record_update_failure( + history_entry: SoilDataHistory, reason: SoilDataPushFailureReason, site_id: str + ): + history_entry.update_failure_reason = reason + history_entry.save() + return SoilDataPushEntry(site_id=site_id, result=SoilDataPushEntryFailure(reason=reason)) + + @staticmethod + def get_valid_site_for_soil_update(user: User, site_id: str): + site = Site.objects.filter(id=site_id).first() + + if site is None: + return None, SoilDataPushFailureReason.DOES_NOT_EXIST + + if not check_site_permission(user, SiteAction.ENTER_DATA, Context(site=site)): + return None, SoilDataPushFailureReason.NOT_ALLOWED + + if not hasattr(site, "soil_data"): + site.soil_data = SoilData() + + return site, None + + @staticmethod + def get_entry_result(user: User, soil_data_entry: dict, history_entry: SoilDataHistory): + site_id = soil_data_entry["site_id"] + soil_data = soil_data_entry["soil_data"] + + depth_dependent_data = soil_data.pop("depth_dependent_data") + depth_intervals = soil_data.pop("depth_intervals") + deleted_depth_intervals = soil_data.pop("deleted_depth_intervals") + + 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( + 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 + ) + + 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( + history_entry=history_entry, + site_id=site_id, + reason=SoilDataPushFailureReason.INVALID_DATA, + ) + + @classmethod + def mutate_and_get_payload(cls, root, info, soil_data_entries: list[dict]): + results = [] + user = info.context.user + + with transaction.atomic(): + history_entries = SoilDataPush.record_update(user, soil_data_entries) + + with transaction.atomic(): + for entry, history_entry in zip(soil_data_entries, history_entries): + results.append( + SoilDataPush.get_entry_result( + user=user, soil_data_entry=entry, history_entry=history_entry + ) + ) + + return cls(results=results) From b5fe246aadabc1145a45978c122a03f74a847ae9 Mon Sep 17 00:00:00 2001 From: shrouxm Date: Tue, 29 Oct 2024 17:50:21 -0700 Subject: [PATCH 5/9] test: test push mutation --- .../tests/graphql/mutations/test_soil_data.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/terraso_backend/tests/graphql/mutations/test_soil_data.py b/terraso_backend/tests/graphql/mutations/test_soil_data.py index 65a418754..fb588c3d5 100644 --- a/terraso_backend/tests/graphql/mutations/test_soil_data.py +++ b/terraso_backend/tests/graphql/mutations/test_soil_data.py @@ -30,6 +30,7 @@ SoilData, SoilDataDepthInterval, ) +from apps.soil_id.models.soil_data_history import SoilDataHistory pytestmark = pytest.mark.django_db @@ -780,3 +781,100 @@ def test_apply_to_all(client, project_site, project_manager): db_intervals = SoilDataDepthInterval.objects.filter(soil_data=project_site.soil_data).all() for interval in db_intervals: assert interval.soil_texture_enabled + + +PUSH_SOIL_DATA_QUERY = """ + mutation PushSoilDataMutation($input: SoilDataPushInput!) { + pushSoilData(input: $input) { + results { + siteId + result { + __typename + ... on SoilDataPushEntryFailure { + reason + } + ... on SoilDataPushEntrySuccess { + site { + soilData { + slopeAspect + depthDependentData { + depthInterval { + start + end + } + clayPercent + } + } + } + } + } + } + errors + } + } +""" + + +def test_push_soil_data(client, user): + sites = mixer.cycle(2).blend(Site, owner=user) + + client.force_login(user) + response = graphql_query( + PUSH_SOIL_DATA_QUERY, + input_data={ + "soilDataEntries": [ + { + "siteId": str(sites[0].id), + "soilData": { + "slopeAspect": 10, + "depthDependentData": [], + "depthIntervals": [], + "deletedDepthIntervals": [], + }, + }, + { + "siteId": str(sites[1].id), + "soilData": { + "depthDependentData": [ + {"depthInterval": {"start": 0, "end": 10}, "clayPercent": 5} + ], + "depthIntervals": [], + "deletedDepthIntervals": [], + }, + }, + { + "siteId": str("c9df7deb-6b9d-4c55-8ba6-641acc47dbb2"), + "soilData": { + "depthDependentData": [], + "depthIntervals": [], + "deletedDepthIntervals": [], + }, + }, + ] + }, + client=client, + ) + + print(response.json()) + result = response.json()["data"]["pushSoilData"] + assert result["errors"] is None + assert result["results"][2]["result"]["reason"] == "DOES_NOT_EXIST" + + assert response.json() + + sites[0].refresh_from_db() + sites[1].refresh_from_db() + + 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 + ) + + history_1 = SoilDataHistory.objects.get(site=sites[0]) + assert history_1.soil_data_changes["soil_data"]["slope_aspect"] == 10 + + history_2 = SoilDataHistory.objects.get(site=sites[1]) + assert history_2.soil_data_changes["soil_data"]["depth_dependent_data"][0]["clay_percent"] == 5 From a60d193a4319457c03a015d9534579061c1785d4 Mon Sep 17 00:00:00 2001 From: shrouxm Date: Wed, 30 Oct 2024 11:27:36 -0700 Subject: [PATCH 6/9] 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 From ac4a2e45e8f12e32b3beedd1077dd4edf9611396 Mon Sep 17 00:00:00 2001 From: shrouxm Date: Wed, 30 Oct 2024 11:39:34 -0700 Subject: [PATCH 7/9] lint: appease linter --- terraso_backend/tests/graphql/mutations/test_soil_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/terraso_backend/tests/graphql/mutations/test_soil_data.py b/terraso_backend/tests/graphql/mutations/test_soil_data.py index 81cf7b518..07d97f952 100644 --- a/terraso_backend/tests/graphql/mutations/test_soil_data.py +++ b/terraso_backend/tests/graphql/mutations/test_soil_data.py @@ -1015,7 +1015,7 @@ def test_push_soil_data_success(client, user): site.soil_data.depth_intervals.get( depth_interval_start=0, depth_interval_end=10 ).soil_texture_enabled - == True + is True ) assert not site.soil_data.depth_intervals.filter( depth_interval_start=10, depth_interval_end=20 @@ -1030,6 +1030,6 @@ def test_push_soil_data_success(client, user): 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["depth_intervals"][0]["soil_texture_enabled"] is True assert history.soil_data_changes["deleted_depth_intervals"][0]["start"] == 10 assert history.soil_data_changes["deleted_depth_intervals"][0]["end"] == 20 From 8360648b999d0d9bdc72b5a9ebab273f94501232 Mon Sep 17 00:00:00 2001 From: shrouxm Date: Thu, 31 Oct 2024 11:26:08 -0700 Subject: [PATCH 8/9] feat: final post-review cleanup --- .../apps/graphql/schema/schema.graphql | 10 +- terraso_backend/apps/graphql/schema/schema.py | 16 ++- .../graphql/soil_data/push_mutation.py | 81 ++++++----- .../apps/soil_id/models/soil_data_history.py | 6 + .../tests/graphql/mutations/test_soil_data.py | 126 +++++++++--------- 5 files changed, 132 insertions(+), 107 deletions(-) diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index eeee271cf..d60394b17 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -1642,11 +1642,11 @@ type Mutations { deleteUserFromProject(input: ProjectDeleteUserMutationInput!): ProjectDeleteUserMutationPayload! updateUserRoleInProject(input: ProjectUpdateUserRoleMutationInput!): ProjectUpdateUserRoleMutationPayload! markProjectSeen(input: ProjectMarkSeenMutationInput!): ProjectMarkSeenMutationPayload! - updateSoilData(input: SoilDataUpdateMutationInput!): SoilDataUpdateMutationPayload! - updateDepthDependentSoilData(input: DepthDependentSoilDataUpdateMutationInput!): DepthDependentSoilDataUpdateMutationPayload! + updateSoilData(input: SoilDataUpdateMutationInput!): SoilDataUpdateMutationPayload! @deprecated(reason: "Use push_soil_data instead.") + updateDepthDependentSoilData(input: DepthDependentSoilDataUpdateMutationInput!): DepthDependentSoilDataUpdateMutationPayload! @deprecated(reason: "Use push_soil_data instead.") pushSoilData(input: SoilDataPushInput!): SoilDataPushPayload! - updateSoilDataDepthInterval(input: SoilDataUpdateDepthIntervalMutationInput!): SoilDataUpdateDepthIntervalMutationPayload! - deleteSoilDataDepthInterval(input: SoilDataDeleteDepthIntervalMutationInput!): SoilDataDeleteDepthIntervalMutationPayload! + updateSoilDataDepthInterval(input: SoilDataUpdateDepthIntervalMutationInput!): SoilDataUpdateDepthIntervalMutationPayload! @deprecated(reason: "Use push_soil_data instead.") + deleteSoilDataDepthInterval(input: SoilDataDeleteDepthIntervalMutationInput!): SoilDataDeleteDepthIntervalMutationPayload! @deprecated(reason: "Use push_soil_data instead.") updateProjectSoilSettings(input: ProjectSoilSettingsUpdateMutationInput!): ProjectSoilSettingsUpdateMutationPayload! updateProjectSoilSettingsDepthInterval(input: ProjectSoilSettingsUpdateDepthIntervalMutationInput!): ProjectSoilSettingsUpdateDepthIntervalMutationPayload! deleteProjectSoilSettingsDepthInterval(input: ProjectSoilSettingsDeleteDepthIntervalMutationInput!): ProjectSoilSettingsDeleteDepthIntervalMutationPayload! @@ -2291,7 +2291,7 @@ type SoilDataPushEntry { union SoilDataPushEntryResult = SoilDataPushEntrySuccess | SoilDataPushEntryFailure type SoilDataPushEntrySuccess { - site: SiteNode! + soilData: SoilDataNode! } type SoilDataPushEntryFailure { diff --git a/terraso_backend/apps/graphql/schema/schema.py b/terraso_backend/apps/graphql/schema/schema.py index 850a6b78d..514216e5c 100644 --- a/terraso_backend/apps/graphql/schema/schema.py +++ b/terraso_backend/apps/graphql/schema/schema.py @@ -190,11 +190,19 @@ class Mutations(graphene.ObjectType): delete_user_from_project = ProjectDeleteUserMutation.Field() update_user_role_in_project = ProjectUpdateUserRoleMutation.Field() mark_project_seen = ProjectMarkSeenMutation.Field() - update_soil_data = SoilDataUpdateMutation.Field() - update_depth_dependent_soil_data = DepthDependentSoilDataUpdateMutation.Field() + update_soil_data = SoilDataUpdateMutation.Field( + deprecation_reason="Use push_soil_data instead." + ) + update_depth_dependent_soil_data = DepthDependentSoilDataUpdateMutation.Field( + deprecation_reason="Use push_soil_data instead." + ) push_soil_data = SoilDataPush.Field() - update_soil_data_depth_interval = SoilDataUpdateDepthIntervalMutation.Field() - delete_soil_data_depth_interval = SoilDataDeleteDepthIntervalMutation.Field() + update_soil_data_depth_interval = SoilDataUpdateDepthIntervalMutation.Field( + deprecation_reason="Use push_soil_data instead." + ) + delete_soil_data_depth_interval = SoilDataDeleteDepthIntervalMutation.Field( + deprecation_reason="Use push_soil_data instead." + ) update_project_soil_settings = ProjectSoilSettingsUpdateMutation.Field() update_project_soil_settings_depth_interval = ( ProjectSoilSettingsUpdateDepthIntervalMutation.Field() 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 9eb3b54ba..b34334f06 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 @@ -7,10 +7,10 @@ from apps.core.models.users import User from apps.graphql.schema.commons import BaseWriteMutation -from apps.graphql.schema.sites import SiteNode from apps.project_management.models.sites import Site from apps.project_management.permission_rules import Context from apps.project_management.permission_table import SiteAction, check_site_permission +from apps.soil_id.graphql.soil_data.queries import SoilDataNode from apps.soil_id.graphql.soil_data.types import ( SoilDataDepthDependentInputs, SoilDataDepthIntervalInputs, @@ -24,10 +24,9 @@ class SoilDataPushEntrySuccess(graphene.ObjectType): - site = graphene.Field(SiteNode, required=True) + soil_data = graphene.Field(SoilDataNode, required=True) -# TODO: just a generic "can't access" result? class SoilDataPushFailureReason(graphene.Enum): DOES_NOT_EXIST = "DOES_NOT_EXIST" NOT_ALLOWED = "NOT_ALLOWED" @@ -73,6 +72,14 @@ class SoilDataPushInputEntry(graphene.InputObjectType): soil_data = graphene.Field(graphene.NonNull(SoilDataPushInputSoilData)) +# NOTE: we check if the user has all permissions required to edit a site, +# rather than checking individual permissions against which data has been modified +# NOTE: we catch errors at the granularity of each site in the request. +# so one site's updates can succeed while another fails. but if any of +# an individual site's updates are invalid, we reject all of that site's updates +# NOTE: changing a depth interval preset causes all depth intervals for that site to be +# deleted. we haven't yet thought through the implications of when/whether to apply +# that change in the context of this mutation class SoilDataPush(BaseWriteMutation): results = graphene.Field(graphene.List(graphene.NonNull(SoilDataPushEntry)), required=True) @@ -82,7 +89,7 @@ class Input: ) @staticmethod - def record_soil_data_push(user: User, soil_data_entries: list[dict]) -> list[SoilDataHistory]: + def log_soil_data_push(user: User, soil_data_entries: list[dict]) -> list[SoilDataHistory]: history_entries = [] for entry in soil_data_entries: @@ -96,7 +103,7 @@ def record_soil_data_push(user: User, soil_data_entries: list[dict]) -> list[Soi return history_entries @staticmethod - def record_entry_failure( + def log_soil_data_push_entry_failure( history_entry: SoilDataHistory, reason: SoilDataPushFailureReason, site_id: str ): history_entry.update_failure_reason = reason.value @@ -104,7 +111,7 @@ def record_entry_failure( return SoilDataPushEntry(site_id=site_id, result=SoilDataPushEntryFailure(reason=reason)) @staticmethod - def get_valid_site_for_soil_update(user: User, site_id: str): + def validate_site_for_soil_update(user: User, site_id: str): site = Site.objects.filter(id=site_id).first() if site is None: @@ -119,25 +126,25 @@ def get_valid_site_for_soil_update(user: User, site_id: str): if not hasattr(site, "soil_data"): site.soil_data = SoilData() - return site, None + return site.soil_data, None @staticmethod - def save_soil_data(site: Site, soil_data: dict): + def update_soil_data(soil_data: SoilData, update_data: dict): if ( - "depth_interval_preset" in soil_data - and soil_data["depth_interval_preset"] != site.soil_data.depth_interval_preset + "depth_interval_preset" in update_data + and update_data["depth_interval_preset"] != soil_data.depth_interval_preset ): - site.soil_data.depth_intervals.all().delete() + soil_data.depth_intervals.all().delete() BaseWriteMutation.assign_graphql_fields_to_model_instance( - model_instance=site.soil_data, fields=soil_data + model_instance=soil_data, fields=update_data ) @staticmethod - def save_depth_dependent_data(site: Site, depth_dependent_data: list[dict]): + def update_depth_dependent_data(soil_data: SoilData, 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, _ = soil_data.depth_dependent_data.get_or_create( depth_interval_start=interval["start"], depth_interval_end=interval["end"], ) @@ -147,10 +154,10 @@ def save_depth_dependent_data(site: Site, depth_dependent_data: list[dict]): ) @staticmethod - def update_depth_intervals(site: Site, depth_intervals: list[dict]): + def update_depth_intervals(soil_data: SoilData, 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, _ = soil_data.depth_intervals.get_or_create( depth_interval_start=interval_input["start"], depth_interval_end=interval_input["end"], ) @@ -160,41 +167,47 @@ def update_depth_intervals(site: Site, depth_intervals: list[dict]): ) @staticmethod - def delete_depth_intervals(site: Site, deleted_depth_intervals: list[dict]): + def delete_depth_intervals(soil_data: SoilData, deleted_depth_intervals: list[dict]): for interval in deleted_depth_intervals: - site.soil_data.depth_intervals.filter( + 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): + def mutate_and_get_entry_result( + user: User, soil_data_entry: dict, history_entry: SoilDataHistory + ): site_id = soil_data_entry["site_id"] - soil_data = soil_data_entry["soil_data"] + update_data = soil_data_entry["soil_data"] - depth_dependent_data = soil_data.pop("depth_dependent_data") - depth_intervals = soil_data.pop("depth_intervals") - deleted_depth_intervals = soil_data.pop("deleted_depth_intervals") + depth_dependent_data = update_data.pop("depth_dependent_data") + depth_intervals = update_data.pop("depth_intervals") + deleted_depth_intervals = update_data.pop("deleted_depth_intervals") try: - site, reason = SoilDataPush.get_valid_site_for_soil_update(user=user, site_id=site_id) - if site is None: - return SoilDataPush.record_entry_failure( + soil_data, reason = SoilDataPush.validate_site_for_soil_update( + user=user, site_id=site_id + ) + if soil_data is None: + return SoilDataPush.log_soil_data_push_entry_failure( history_entry=history_entry, site_id=site_id, reason=reason, ) - 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) + SoilDataPush.update_soil_data(soil_data, update_data) + SoilDataPush.update_depth_intervals(soil_data, depth_intervals) + SoilDataPush.update_depth_dependent_data(soil_data, depth_dependent_data) + SoilDataPush.delete_depth_intervals(soil_data, deleted_depth_intervals) history_entry.update_succeeded = True history_entry.save() - return SoilDataPushEntry(site_id=site_id, result=SoilDataPushEntrySuccess(site=site)) + return SoilDataPushEntry( + site_id=site_id, result=SoilDataPushEntrySuccess(soil_data=soil_data) + ) except (ValidationError, IntegrityError): - return SoilDataPush.record_entry_failure( + return SoilDataPush.log_soil_data_push_entry_failure( history_entry=history_entry, site_id=site_id, reason=SoilDataPushFailureReason.INVALID_DATA, @@ -206,12 +219,12 @@ def mutate_and_get_payload(cls, root, info, soil_data_entries: list[dict]): user = info.context.user with transaction.atomic(): - history_entries = SoilDataPush.record_soil_data_push(user, soil_data_entries) + history_entries = SoilDataPush.log_soil_data_push(user, soil_data_entries) with transaction.atomic(): for entry, history_entry in zip(soil_data_entries, history_entries): results.append( - SoilDataPush.get_entry_result( + SoilDataPush.mutate_and_get_entry_result( user=user, soil_data_entry=entry, history_entry=history_entry ) ) diff --git a/terraso_backend/apps/soil_id/models/soil_data_history.py b/terraso_backend/apps/soil_id/models/soil_data_history.py index cf42981bd..58919c162 100644 --- a/terraso_backend/apps/soil_id/models/soil_data_history.py +++ b/terraso_backend/apps/soil_id/models/soil_data_history.py @@ -20,6 +20,12 @@ from apps.project_management.models.sites import Site +# NOTE: this table may contain data associated with sites that was submitted +# by unauthorized (but still authenticated) users. such requests may have +# an update_failure_reason of null or "NOT_ALLOWED". unless a user is +# handcrafting malicious requests, this will be because a user had +# authorization to enter data for a site, then went offline and made +# changes simultaneous to losing authorization to enter data for that site class SoilDataHistory(BaseModel): site = models.ForeignKey(Site, null=True, on_delete=models.CASCADE) changed_by = models.ForeignKey(User, on_delete=models.CASCADE) diff --git a/terraso_backend/tests/graphql/mutations/test_soil_data.py b/terraso_backend/tests/graphql/mutations/test_soil_data.py index 07d97f952..61be4b438 100644 --- a/terraso_backend/tests/graphql/mutations/test_soil_data.py +++ b/terraso_backend/tests/graphql/mutations/test_soil_data.py @@ -794,69 +794,67 @@ def test_apply_to_all(client, project_site, project_manager): reason } ... 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 + soilData { + downSlope + crossSlope + bedrock + depthIntervalPreset + slopeLandscapePosition + slopeAspect + slopeSteepnessSelect + slopeSteepnessPercent + slopeSteepnessDegree + surfaceCracksSelect + surfaceSaltSelect + floodingSelect + limeRequirementsSelect + surfaceStoninessSelect + waterTableDepthSelect + soilDepthSelect + landCoverSelect + grazingSelect + depthDependentData { + depthInterval { + start + end } - depthIntervals { - label - depthInterval { - start - end - } - soilTextureEnabled - soilColorEnabled - carbonatesEnabled - phEnabled - soilOrganicCarbonMatterEnabled - electricalConductivityEnabled - sodiumAdsorptionRatioEnabled - soilStructureEnabled + 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 } } } @@ -924,9 +922,10 @@ def test_push_soil_data_can_process_mixed_results(client, user): ) assert response.json() + assert "data" in response.json() result = response.json()["data"]["pushSoilData"] assert result["errors"] is None - assert result["results"][0]["result"]["site"]["soilData"]["slopeAspect"] == 10 + assert result["results"][0]["result"]["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" @@ -961,7 +960,6 @@ def test_push_soil_data_can_process_mixed_results(client, user): 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) @@ -1000,7 +998,7 @@ def test_push_soil_data_success(client, user): assert response.json() result = response.json()["data"]["pushSoilData"] assert result["errors"] is None - assert result["results"][0]["result"]["site"]["soilData"]["slopeAspect"] == 10 + assert result["results"][0]["result"]["soilData"]["slopeAspect"] == 10 site.refresh_from_db() From 9b91aaaee4f41f10a1c8589c66384c1ce1ebe8a4 Mon Sep 17 00:00:00 2001 From: shrouxm Date: Thu, 31 Oct 2024 14:40:31 -0700 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=A7=B9:=20last=20couple=20PR=20commen?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../graphql/soil_data/push_mutation.py | 3 +- .../tests/graphql/mutations/test_soil_data.py | 146 +++++++++--------- 2 files changed, 75 insertions(+), 74 deletions(-) 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 b34334f06..b3f86c114 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 @@ -79,7 +79,8 @@ class SoilDataPushInputEntry(graphene.InputObjectType): # an individual site's updates are invalid, we reject all of that site's updates # NOTE: changing a depth interval preset causes all depth intervals for that site to be # deleted. we haven't yet thought through the implications of when/whether to apply -# that change in the context of this mutation +# that change in the context of this mutation. this work is tracked here: +# https://github.com/techmatters/terraso-backend/issues/1527 class SoilDataPush(BaseWriteMutation): results = graphene.Field(graphene.List(graphene.NonNull(SoilDataPushEntry)), required=True) diff --git a/terraso_backend/tests/graphql/mutations/test_soil_data.py b/terraso_backend/tests/graphql/mutations/test_soil_data.py index 61be4b438..003065b44 100644 --- a/terraso_backend/tests/graphql/mutations/test_soil_data.py +++ b/terraso_backend/tests/graphql/mutations/test_soil_data.py @@ -866,6 +866,79 @@ def test_apply_to_all(client, project_site, project_manager): """ +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, + ) + + assert response.json() + result = response.json()["data"]["pushSoilData"] + assert result["errors"] is None + assert result["results"][0]["result"]["soilData"]["slopeAspect"] == 10 + + site.refresh_from_db() + + 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 + is 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"] is True + assert history.soil_data_changes["deleted_depth_intervals"][0]["start"] == 10 + assert history.soil_data_changes["deleted_depth_intervals"][0]["end"] == 20 + + 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) @@ -958,76 +1031,3 @@ def test_push_soil_data_can_process_mixed_results(client, user): assert history_3.update_failure_reason == "DOES_NOT_EXIST" assert not history_3.update_succeeded assert history_3.soil_data_changes["slope_aspect"] == 15 - - -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, - ) - - assert response.json() - result = response.json()["data"]["pushSoilData"] - assert result["errors"] is None - assert result["results"][0]["result"]["soilData"]["slopeAspect"] == 10 - - site.refresh_from_db() - - 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 - is 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"] is True - assert history.soil_data_changes["deleted_depth_intervals"][0]["start"] == 10 - assert history.soil_data_changes["deleted_depth_intervals"][0]["end"] == 20