From 9bf5a17de8ae311d77c954e097493961350a6025 Mon Sep 17 00:00:00 2001 From: shrouxm Date: Thu, 26 Sep 2024 16:25:56 -0700 Subject: [PATCH] prototype v1 --- .../apps/graphql/schema/schema.graphql | 84 ++++++++++- terraso_backend/apps/graphql/schema/schema.py | 2 + .../apps/soil_id/graphql/soil_data.py | 94 ++++++------ .../graphql/soil_id/soil_data/types.py | 137 ++++++++++++++++++ .../tests/graphql/mutations/test_soil_data.py | 77 ++++++++++ 5 files changed, 348 insertions(+), 46 deletions(-) create mode 100644 terraso_backend/apps/soil_id/graphql/soil_id/soil_data/types.py diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index 52a4ae751..075e6a62e 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -1712,6 +1712,7 @@ type Mutations { markProjectSeen(input: ProjectMarkSeenMutationInput!): ProjectMarkSeenMutationPayload! updateSoilData(input: SoilDataUpdateMutationInput!): SoilDataUpdateMutationPayload! updateDepthDependentSoilData(input: DepthDependentSoilDataUpdateMutationInput!): DepthDependentSoilDataUpdateMutationPayload! + bulkUpdateSoilData(input: SoilDataBulkUpdateInput!): SoilDataBulkUpdatePayload! updateSoilDataDepthInterval(input: SoilDataUpdateDepthIntervalMutationInput!): SoilDataUpdateDepthIntervalMutationPayload! deleteSoilDataDepthInterval(input: SoilDataDeleteDepthIntervalMutationInput!): SoilDataDeleteDepthIntervalMutationPayload! updateProjectSoilSettings(input: ProjectSoilSettingsUpdateMutationInput!): ProjectSoilSettingsUpdateMutationPayload! @@ -2281,7 +2282,6 @@ type SoilDataUpdateMutationPayload { } input SoilDataUpdateMutationInput { - siteId: ID! downSlope: SoilIdSoilDataDownSlopeChoices crossSlope: SoilIdSoilDataCrossSlopeChoices bedrock: Int @@ -2299,6 +2299,7 @@ input SoilDataUpdateMutationInput { soilDepthSelect: SoilIdSoilDataSoilDepthSelectChoices landCoverSelect: SoilIdSoilDataLandCoverSelectChoices grazingSelect: SoilIdSoilDataGrazingSelectChoices + siteId: ID! depthIntervalPreset: SoilIdSoilDataDepthIntervalPresetChoices clientMutationId: String } @@ -2310,7 +2311,6 @@ type DepthDependentSoilDataUpdateMutationPayload { } input DepthDependentSoilDataUpdateMutationInput { - siteId: ID! depthInterval: DepthIntervalInput! texture: SoilIdDepthDependentSoilDataTextureChoices clayPercent: Int @@ -2334,9 +2334,89 @@ input DepthDependentSoilDataUpdateMutationInput { soilOrganicMatterTesting: SoilIdDepthDependentSoilDataSoilOrganicMatterTestingChoices sodiumAbsorptionRatio: Decimal carbonates: SoilIdDepthDependentSoilDataCarbonatesChoices + siteId: ID! + clientMutationId: String +} + +type SoilDataBulkUpdatePayload { + errors: GenericScalar + results: [SoilDataBulkUpdateResultEntry!]! + clientMutationId: String +} + +type SoilDataBulkUpdateResultEntry { + siteId: ID! + result: SoilDataBulkUpdateResult! +} + +union SoilDataBulkUpdateResult = SoilDataBulkUpdateSuccess | SoilDataBulkUpdateFailure + +type SoilDataBulkUpdateSuccess { + soilData: SoilDataNode! +} + +type SoilDataBulkUpdateFailure { + reason: SoilDataBulkUpdateFailureReason! +} + +enum SoilDataBulkUpdateFailureReason { + SITE_DELETED + LOST_PERMISSION +} + +input SoilDataBulkUpdateInput { + soilData: [SoilDataBulkUpdateEntry!]! clientMutationId: String } +input SoilDataBulkUpdateEntry { + 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 + siteId: ID! + depthIntervals: [SoilDataBulkUpdateDepthDependentEntry!]! +} + +input SoilDataBulkUpdateDepthDependentEntry { + 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 +} + 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 1dc8e6fdf..299027e45 100644 --- a/terraso_backend/apps/graphql/schema/schema.py +++ b/terraso_backend/apps/graphql/schema/schema.py @@ -42,6 +42,7 @@ SoilDataUpdateMutation, ) from apps.soil_id.graphql.soil_id.endpoints import soil_id +from apps.soil_id.graphql.soil_id.soil_data.types import SoilDataBulkUpdate from .audit_logs import AuditLogNode from .commons import TerrasoRelayNode @@ -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() + bulk_update_soil_data = SoilDataBulkUpdate.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.py b/terraso_backend/apps/soil_id/graphql/soil_data.py index e2ea9fd16..aa87157fc 100644 --- a/terraso_backend/apps/soil_id/graphql/soil_data.py +++ b/terraso_backend/apps/soil_id/graphql/soil_data.py @@ -325,29 +325,32 @@ def mutate_and_get_payload(cls, root, info, site_id, depth_interval, **kwargs): return SoilDataDeleteDepthIntervalMutation(soil_data=site.soil_data) +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 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 @@ -373,38 +376,41 @@ def mutate_and_get_payload(cls, root, info, site_id, **kwargs): return result +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() + + class DepthDependentSoilDataUpdateMutation(BaseWriteMutation): soil_data = graphene.Field(SoilDataNode) 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_id/soil_data/types.py b/terraso_backend/apps/soil_id/graphql/soil_id/soil_data/types.py new file mode 100644 index 000000000..200e460e2 --- /dev/null +++ b/terraso_backend/apps/soil_id/graphql/soil_id/soil_data/types.py @@ -0,0 +1,137 @@ +import enum +import traceback + +import graphene +import structlog +from django.db import transaction + +from apps.graphql.schema.commons import BaseWriteMutation +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 import ( + SoilDataDepthDependentInputs, + SoilDataInputs, + SoilDataNode, +) +from apps.soil_id.models.soil_data import SoilData + +logger = structlog.get_logger(__name__) + + +class SoilDataBulkUpdateSuccess(graphene.ObjectType): + soil_data = graphene.Field(SoilDataNode, required=True) + + +# TODO: just a generic "can't access" result? +class SoilDataBulkUpdateFailureReason(graphene.Enum): + DOES_NOT_EXIST = "DOES_NOT_EXIST" + NOT_ALLOWED = "NOT_ALLOWED" + + +class SoilDataBulkUpdateFailure(graphene.ObjectType): + reason = graphene.Field(SoilDataBulkUpdateFailureReason, required=True) + + +class SoilDataBulkUpdateResult(graphene.Union): + class Meta: + types = (SoilDataBulkUpdateSuccess, SoilDataBulkUpdateFailure) + + +class SoilDataBulkUpdateResultEntry(graphene.ObjectType): + site_id = graphene.ID(required=True) + result = graphene.Field(SoilDataBulkUpdateResult, required=True) + + +class SoilDataBulkUpdateDepthDependentEntry(SoilDataDepthDependentInputs, graphene.InputObjectType): + pass + + +class SoilDataBulkUpdateEntry(SoilDataInputs, graphene.InputObjectType): + site_id = graphene.ID(required=True) + depth_intervals = graphene.Field( + graphene.List(graphene.NonNull(SoilDataBulkUpdateDepthDependentEntry)), required=True + ) + + +class SoilDataBulkUpdate(BaseWriteMutation): + results = graphene.Field( + graphene.List(graphene.NonNull(SoilDataBulkUpdateResultEntry)), required=True + ) + + class Input: + soil_data = graphene.Field( + graphene.List(graphene.NonNull(SoilDataBulkUpdateEntry)), required=True + ) + + @classmethod + def mutate_and_get_payload(cls, root, info, soil_data): + try: + results = [] + + with transaction.atomic(): + for entry in soil_data: + site_id = entry.pop("site_id") + depth_intervals = entry.pop("depth_intervals") + + site = Site.objects.filter(id=site_id).first() + + if site is None: + results.append( + SoilDataBulkUpdateResultEntry( + site_id=site_id, + result=SoilDataBulkUpdateFailure( + reason=SoilDataBulkUpdateFailureReason.DOES_NOT_EXIST + ), + ) + ) + continue + + user = info.context.user + if not check_site_permission(user, SiteAction.ENTER_DATA, Context(site=site)): + results.append( + SoilDataBulkUpdateResultEntry( + site_id=entry["site_id"], + result=SoilDataBulkUpdateFailure( + reason=SoilDataBulkUpdateFailureReason.NOT_ALLOWED + ), + ) + ) + continue + + if not hasattr(site, "soil_data"): + site.soil_data = SoilData() + + for attr, value in entry.items(): + if isinstance(value, enum.Enum): + value = value.value + setattr(site.soil_data, attr, value) + + site.soil_data.save() + + for depth_interval_input in depth_intervals: + interval = depth_interval_input.pop("depth_interval") + depth_interval, _ = site.soil_data.depth_dependent_data.get_or_create( + depth_interval_start=interval["start"], + depth_interval_end=interval["end"], + ) + + for attr, value in depth_interval_input.items(): + if isinstance(value, enum.Enum): + value = value.value + setattr(depth_interval, attr, value) + + depth_interval.save() + + results.append( + SoilDataBulkUpdateResultEntry( + site_id=site_id, + result=SoilDataBulkUpdateSuccess(soil_data=site.soil_data), + ) + ) + + logger.info(results) + + return cls(results=results) + except Exception as exc: + logger.info(traceback.format_exc(exc)) diff --git a/terraso_backend/tests/graphql/mutations/test_soil_data.py b/terraso_backend/tests/graphql/mutations/test_soil_data.py index 65a418754..8f7022c39 100644 --- a/terraso_backend/tests/graphql/mutations/test_soil_data.py +++ b/terraso_backend/tests/graphql/mutations/test_soil_data.py @@ -780,3 +780,80 @@ 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 + + +BULK_UPDATE_QUERY = """ + mutation BulkSoilDataUpdateMutation($input: SoilDataBulkUpdateInput!) { + bulkUpdateSoilData(input: $input) { + results { + siteId + result { + __typename + ... on SoilDataBulkUpdateFailure { + reason + } + ... on SoilDataBulkUpdateSuccess { + soilData { + slopeAspect + depthDependentData { + depthInterval { + start + end + } + clayPercent + } + } + } + } + } + errors + } + } +""" + + +def test_bulk_update(client, user): + sites = mixer.cycle(2).blend(Site, owner=user) + + client.force_login(user) + response = graphql_query( + BULK_UPDATE_QUERY, + input_data={ + "soilData": [ + { + "siteId": str(sites[0].id), + "slopeAspect": 10, + "depthIntervals": [], + }, + { + "siteId": str(sites[1].id), + "depthIntervals": [ + {"depthInterval": {"start": 0, "end": 10}, "clayPercent": 5} + ], + }, + { + "siteId": str("c9df7deb-6b9d-4c55-8ba6-641acc47dbb2"), + "depthIntervals": [], + }, + ] + }, + client=client, + ) + + print(response.json()) + result = response.json()["data"]["bulkUpdateSoilData"] + 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 + )