diff --git a/terraso_backend/apps/graphql/schema/__init__.py b/terraso_backend/apps/graphql/schema/__init__.py index 68a0622c2..1bef80560 100644 --- a/terraso_backend/apps/graphql/schema/__init__.py +++ b/terraso_backend/apps/graphql/schema/__init__.py @@ -18,6 +18,11 @@ from apps.soil_id.graphql.soil_data import ( DepthDependentSoilDataUpdateMutation, + ProjectSoilSettingsDeleteDepthIntervalMutation, + ProjectSoilSettingsUpdateDepthIntervalMutation, + ProjectSoilSettingsUpdateMutation, + SoilDataDeleteDepthIntervalMutation, + SoilDataUpdateDepthIntervalMutation, SoilDataUpdateMutation, ) @@ -172,6 +177,15 @@ class Mutations(graphene.ObjectType): mark_project_seen = ProjectMarkSeenMutation.Field() update_soil_data = SoilDataUpdateMutation.Field() update_depth_dependent_soil_data = DepthDependentSoilDataUpdateMutation.Field() + update_soil_data_depth_interval = SoilDataUpdateDepthIntervalMutation.Field() + delete_soil_data_depth_interval = SoilDataDeleteDepthIntervalMutation.Field() + update_project_soil_settings = ProjectSoilSettingsUpdateMutation.Field() + update_project_soil_settings_depth_interval = ( + ProjectSoilSettingsUpdateDepthIntervalMutation.Field() + ) + delete_project_soil_settings_depth_interval = ( + ProjectSoilSettingsDeleteDepthIntervalMutation.Field() + ) schema = graphene.Schema(query=Query, mutation=Mutations) diff --git a/terraso_backend/apps/graphql/schema/commons.py b/terraso_backend/apps/graphql/schema/commons.py index 6dda9a000..f5e9d85b4 100644 --- a/terraso_backend/apps/graphql/schema/commons.py +++ b/terraso_backend/apps/graphql/schema/commons.py @@ -92,6 +92,10 @@ def mutate(cls, root, info, input): ) return cls(errors=[{"message": str(error)}]) + @classmethod + def not_found(cls, model=None, field=None, msg=None): + raise GraphQLNotFoundException(msg, field=field, model_name=model.__name__) + @classmethod def get_or_throw(cls, model, field_name, id_): try: @@ -161,6 +165,11 @@ def not_allowed(cls, mutation_type=None, msg=None, extra=None): def not_allowed_create(cls, model, msg=None, extra=None): raise cls.not_allowed(MutationTypes.CREATE, msg, extra) + @classmethod + def not_found(cls, model=None, field=None, msg=None): + model = model or cls.model_class + raise GraphQLNotFoundException(msg, field=field, model_name=model.__name__) + class BaseWriteMutation(BaseAuthenticatedMutation): logger: Optional[audit_log_api.AuditLog] = None @@ -244,13 +253,16 @@ def get_logger(cls): class BaseDeleteMutation(BaseAuthenticatedMutation): @classmethod def mutate_and_get_payload(cls, root, info, **kwargs): - _id = kwargs.pop("id", None) - - if not _id: - model_instance = None + if "model_instance" in kwargs: + model_instance = kwargs.pop("model_instance") else: - model_instance = cls.model_class.objects.get(pk=_id) - model_instance.delete() + _id = kwargs.pop("id", None) + + if not _id: + model_instance = None + else: + model_instance = cls.model_class.objects.get(pk=_id) + model_instance.delete() result_kwargs = {from_camel_to_snake_case(cls.model_class.__name__): model_instance} return cls(**result_kwargs) diff --git a/terraso_backend/apps/graphql/schema/projects.py b/terraso_backend/apps/graphql/schema/projects.py index e0765c96b..fc479a701 100644 --- a/terraso_backend/apps/graphql/schema/projects.py +++ b/terraso_backend/apps/graphql/schema/projects.py @@ -25,6 +25,7 @@ from apps.audit_logs import api as log_api from apps.project_management.models import Project from apps.project_management.models.sites import Site +from apps.soil_id.models.project_soil_settings import ProjectSoilSettings from .commons import ( BaseAuthenticatedMutation, @@ -46,12 +47,26 @@ class Meta: class ProjectNode(DjangoObjectType): id = graphene.ID(source="pk", required=True) seen = graphene.Boolean(required=True) + soil_settings = graphene.Field( + "apps.soil_id.graphql.soil_data.ProjectSoilSettingsNode", + required=True, + default_value=ProjectSoilSettings(), + ) class Meta: model = Project filterset_class = ProjectFilterSet - fields = ("name", "privacy", "description", "updated_at", "group", "site_set", "archived") + fields = ( + "name", + "privacy", + "description", + "updated_at", + "group", + "site_set", + "archived", + "soil_settings", + ) interfaces = (relay.Node,) connection_class = TerrasoConnection diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index c459bf720..7b7d75340 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -654,6 +654,7 @@ type ProjectNode implements Node { """Ordering""" orderBy: String ): SiteNodeConnection! + soilSettings: ProjectSoilSettingsNode! id: ID! seen: Boolean! } @@ -694,9 +695,9 @@ type SiteNode implements Node { privacy: ProjectManagementSitePrivacyChoices! project: ProjectNode archived: Boolean! - soilData: SoilDataNode id: ID! seen: Boolean! + soilData: SoilDataNode! } """An enumeration.""" @@ -709,6 +710,7 @@ enum ProjectManagementSitePrivacyChoices { } type SoilDataNode { + site: SiteNode! downSlope: SoilIdSoilDataDownSlopeChoices crossSlope: SoilIdSoilDataCrossSlopeChoices bedrock: Int @@ -717,7 +719,7 @@ type SoilDataNode { slopeSteepnessSelect: SoilIdSoilDataSlopeSteepnessSelectChoices slopeSteepnessPercent: Int slopeSteepnessDegree: Int - depthIntervals: [DepthInterval!]! + depthIntervals: [SoilDataDepthIntervalNode!]! depthDependentData: [DepthDependentSoilDataNode!]! } @@ -814,14 +816,30 @@ enum SoilIdSoilDataSlopeSteepnessSelectChoices { STEEPEST } +type SoilDataDepthIntervalNode { + label: String! + slopeEnabled: Boolean! + soilTextureEnabled: Boolean! + soilColorEnabled: Boolean! + verticalCrackingEnabled: Boolean! + carbonatesEnabled: Boolean! + phEnabled: Boolean! + soilOrganicCarbonMatterEnabled: Boolean! + electricalConductivityEnabled: Boolean! + sodiumAdsorptionRatioEnabled: Boolean! + soilStructureEnabled: Boolean! + landUseLandCoverEnabled: Boolean! + soilLimitationsEnabled: Boolean! + site: SiteNode! + depthInterval: DepthInterval! +} + type DepthInterval { start: Int! end: Int! } type DepthDependentSoilDataNode { - depthStart: Int! - depthEnd: Int! texture: SoilIdDepthDependentSoilDataTextureChoices rockFragmentVolume: SoilIdDepthDependentSoilDataRockFragmentVolumeChoices colorHueSubstep: SoilIdDepthDependentSoilDataColorHueSubstepChoices @@ -841,6 +859,8 @@ type DepthDependentSoilDataNode { soilOrganicMatterTesting: SoilIdDepthDependentSoilDataSoilOrganicMatterTestingChoices sodiumAbsorptionRatio: Decimal carbonates: SoilIdDepthDependentSoilDataCarbonatesChoices + site: SiteNode! + depthInterval: DepthInterval! } """An enumeration.""" @@ -1188,6 +1208,58 @@ enum SoilIdDepthDependentSoilDataCarbonatesChoices { VIOLENTLY_EFFERVESCENT } +type ProjectSoilSettingsNode { + project: ProjectNode! + measurementUnits: SoilIdProjectSoilSettingsMeasurementUnitsChoices + depthIntervalPreset: SoilIdProjectSoilSettingsDepthIntervalPresetChoices! + soilPitRequired: Boolean! + slopeRequired: Boolean! + soilTextureRequired: Boolean! + soilColorRequired: Boolean! + verticalCrackingRequired: Boolean! + carbonatesRequired: Boolean! + phRequired: Boolean! + soilOrganicCarbonMatterRequired: Boolean! + electricalConductivityRequired: Boolean! + sodiumAdsorptionRatioRequired: Boolean! + soilStructureRequired: Boolean! + landUseLandCoverRequired: Boolean! + soilLimitationsRequired: Boolean! + photosRequired: Boolean! + notesRequired: Boolean! + depthIntervals: [ProjectDepthIntervalNode!]! +} + +"""An enumeration.""" +enum SoilIdProjectSoilSettingsMeasurementUnitsChoices { + """Imperial""" + IMPERIAL + + """Metric""" + METRIC +} + +"""An enumeration.""" +enum SoilIdProjectSoilSettingsDepthIntervalPresetChoices { + """Landpks""" + LANDPKS + + """Nrcs""" + NRCS + + """None""" + NONE + + """Custom""" + CUSTOM +} + +type ProjectDepthIntervalNode { + project: ProjectNode! + label: String! + depthInterval: DepthInterval! +} + type ProjectNodeConnection { """Pagination data for this connection.""" pageInfo: PageInfo! @@ -1306,6 +1378,11 @@ type Mutations { markProjectSeen(input: ProjectMarkSeenMutationInput!): ProjectMarkSeenMutationPayload! updateSoilData(input: SoilDataUpdateMutationInput!): SoilDataUpdateMutationPayload! updateDepthDependentSoilData(input: DepthDependentSoilDataUpdateMutationInput!): DepthDependentSoilDataUpdateMutationPayload! + updateSoilDataDepthInterval(input: SoilDataUpdateDepthIntervalMutationInput!): SoilDataUpdateDepthIntervalMutationPayload! + deleteSoilDataDepthInterval(input: SoilDataDeleteDepthIntervalMutationInput!): SoilDataDeleteDepthIntervalMutationPayload! + updateProjectSoilSettings(input: ProjectSoilSettingsUpdateMutationInput!): ProjectSoilSettingsUpdateMutationPayload! + updateProjectSoilSettingsDepthInterval(input: ProjectSoilSettingsUpdateDepthIntervalMutationInput!): ProjectSoilSettingsUpdateDepthIntervalMutationPayload! + deleteProjectSoilSettingsDepthInterval(input: ProjectSoilSettingsDeleteDepthIntervalMutationInput!): ProjectSoilSettingsDeleteDepthIntervalMutationPayload! } type GroupAddMutationPayload { @@ -1841,25 +1918,18 @@ input SoilDataUpdateMutationInput { slopeSteepnessSelect: SoilIdSoilDataSlopeSteepnessSelectChoices slopeSteepnessPercent: Int slopeSteepnessDegree: Int - depthIntervals: [DepthIntervalInput!] clientMutationId: String } -input DepthIntervalInput { - start: Int! - end: Int! -} - type DepthDependentSoilDataUpdateMutationPayload { errors: GenericScalar - depthDependentSoilData: DepthDependentSoilDataNode + soilData: SoilDataNode clientMutationId: String } input DepthDependentSoilDataUpdateMutationInput { siteId: ID! - depthStart: Int! - depthEnd: Int! + depthInterval: DepthIntervalInput! texture: SoilIdDepthDependentSoilDataTextureChoices rockFragmentVolume: SoilIdDepthDependentSoilDataRockFragmentVolumeChoices colorHueSubstep: SoilIdDepthDependentSoilDataColorHueSubstepChoices @@ -1881,3 +1951,98 @@ input DepthDependentSoilDataUpdateMutationInput { carbonates: SoilIdDepthDependentSoilDataCarbonatesChoices clientMutationId: String } + +input DepthIntervalInput { + start: Int! + end: Int! +} + +type SoilDataUpdateDepthIntervalMutationPayload { + errors: GenericScalar + soilData: SoilDataNode + clientMutationId: String +} + +input SoilDataUpdateDepthIntervalMutationInput { + siteId: ID! + label: String + depthInterval: DepthIntervalInput! + slopeEnabled: Boolean + soilTextureEnabled: Boolean + soilColorEnabled: Boolean + verticalCrackingEnabled: Boolean + carbonatesEnabled: Boolean + phEnabled: Boolean + soilOrganicCarbonMatterEnabled: Boolean + electricalConductivityEnabled: Boolean + sodiumAdsorptionRatioEnabled: Boolean + soilStructureEnabled: Boolean + landUseLandCoverEnabled: Boolean + soilLimitationsEnabled: Boolean + clientMutationId: String +} + +type SoilDataDeleteDepthIntervalMutationPayload { + errors: GenericScalar + soilData: SoilDataNode + clientMutationId: String +} + +input SoilDataDeleteDepthIntervalMutationInput { + siteId: ID! + depthInterval: DepthIntervalInput! + clientMutationId: String +} + +type ProjectSoilSettingsUpdateMutationPayload { + errors: GenericScalar + soilSettings: ProjectSoilSettingsNode + clientMutationId: String +} + +input ProjectSoilSettingsUpdateMutationInput { + projectId: ID! + measurementUnits: SoilIdProjectSoilSettingsMeasurementUnitsChoices + depthIntervalPreset: SoilIdProjectSoilSettingsDepthIntervalPresetChoices + soilPitRequired: Boolean + slopeRequired: Boolean + soilTextureRequired: Boolean + soilColorRequired: Boolean + verticalCrackingRequired: Boolean + carbonatesRequired: Boolean + phRequired: Boolean + soilOrganicCarbonMatterRequired: Boolean + electricalConductivityRequired: Boolean + sodiumAdsorptionRatioRequired: Boolean + soilStructureRequired: Boolean + landUseLandCoverRequired: Boolean + soilLimitationsRequired: Boolean + photosRequired: Boolean + notesRequired: Boolean + clientMutationId: String +} + +type ProjectSoilSettingsUpdateDepthIntervalMutationPayload { + errors: GenericScalar + soilSettings: ProjectSoilSettingsNode + clientMutationId: String +} + +input ProjectSoilSettingsUpdateDepthIntervalMutationInput { + projectId: ID! + label: String + depthInterval: DepthIntervalInput! + clientMutationId: String +} + +type ProjectSoilSettingsDeleteDepthIntervalMutationPayload { + errors: GenericScalar + soilSettings: ProjectSoilSettingsNode + clientMutationId: String +} + +input ProjectSoilSettingsDeleteDepthIntervalMutationInput { + projectId: ID! + depthInterval: DepthIntervalInput! + clientMutationId: String +} diff --git a/terraso_backend/apps/graphql/schema/sites.py b/terraso_backend/apps/graphql/schema/sites.py index 281f005d2..288252398 100644 --- a/terraso_backend/apps/graphql/schema/sites.py +++ b/terraso_backend/apps/graphql/schema/sites.py @@ -23,6 +23,7 @@ from apps.audit_logs import api as audit_log_api from apps.project_management.models import Project, Site, sites +from apps.soil_id.models.soil_data import SoilData from .commons import ( BaseAuthenticatedMutation, @@ -54,6 +55,9 @@ class Meta: class SiteNode(DjangoObjectType): id = graphene.ID(source="pk", required=True) seen = graphene.Boolean(required=True) + soil_data = graphene.Field( + "apps.soil_id.graphql.soil_data.SoilDataNode", required=True, default_value=SoilData() + ) class Meta: model = Site @@ -67,7 +71,6 @@ class Meta: "owner", "privacy", "updated_at", - "soil_data", ) filterset_class = SiteFilter diff --git a/terraso_backend/apps/soil_id/graphql/soil_data.py b/terraso_backend/apps/soil_id/graphql/soil_data.py index 92b592aed..b7d89dcd8 100644 --- a/terraso_backend/apps/soil_id/graphql/soil_data.py +++ b/terraso_backend/apps/soil_id/graphql/soil_data.py @@ -2,11 +2,18 @@ from django.db import transaction from graphene_django import DjangoObjectType -from apps.graphql.schema.commons import BaseWriteMutation +from apps.graphql.schema.commons import BaseAuthenticatedMutation, BaseWriteMutation from apps.graphql.schema.constants import MutationTypes +from apps.graphql.schema.projects import ProjectNode +from apps.graphql.schema.sites import SiteNode +from apps.project_management.models.projects import Project from apps.project_management.models.sites import Site from apps.soil_id.models.depth_dependent_soil_data import DepthDependentSoilData -from apps.soil_id.models.soil_data import SoilData +from apps.soil_id.models.project_soil_settings import ( + ProjectDepthInterval, + ProjectSoilSettings, +) +from apps.soil_id.models.soil_data import SoilData, SoilDataDepthInterval class DepthInterval(graphene.ObjectType): @@ -14,12 +21,31 @@ class DepthInterval(graphene.ObjectType): end = graphene.Int(required=True) -class SoilDataNode(DjangoObjectType): - depth_intervals = graphene.List(graphene.NonNull(DepthInterval), required=True) +class SoilDataDepthIntervalNode(DjangoObjectType): + site = graphene.Field(SiteNode, source="soil_data__site", required=True) + depth_interval = graphene.Field(DepthInterval, required=True) + + class Meta: + model = SoilDataDepthInterval + exclude = [ + "deleted_at", + "deleted_by_cascade", + "id", + "created_at", + "updated_at", + "soil_data", + "depth_interval_start", + "depth_interval_end", + ] + + def resolve_depth_interval(self, info): + return DepthInterval(start=self.depth_interval_start, end=self.depth_interval_end) + +class SoilDataNode(DjangoObjectType): class Meta: model = SoilData - exclude = ["deleted_at", "deleted_by_cascade", "id", "created_at", "updated_at", "site"] + exclude = ["deleted_at", "deleted_by_cascade", "id", "created_at", "updated_at"] @classmethod def down_slope_enum(cls): @@ -38,7 +64,50 @@ def slope_steepness_enum(cls): return cls._meta.fields["slope_steepness_select"].type() +class ProjectSoilSettingsNode(DjangoObjectType): + class Meta: + model = ProjectSoilSettings + exclude = [ + "deleted_at", + "deleted_by_cascade", + "id", + "created_at", + "updated_at", + ] + + @classmethod + def measurement_units_enum(cls): + return cls._meta.fields["measurement_units"].type() + + @classmethod + def depth_interval_preset_enum(cls): + return cls._meta.fields["depth_interval_preset"].type.of_type() + + +class ProjectDepthIntervalNode(DjangoObjectType): + project = graphene.Field(ProjectNode, source="project__project", required=True) + depth_interval = graphene.Field(DepthInterval, required=True) + + class Meta: + model = ProjectDepthInterval + exclude = [ + "deleted_at", + "deleted_by_cascade", + "id", + "created_at", + "updated_at", + "depth_interval_start", + "depth_interval_end", + ] + + def resolve_depth_interval(self, info): + return DepthInterval(start=self.depth_interval_start, end=self.depth_interval_end) + + class DepthDependentSoilDataNode(DjangoObjectType): + site = graphene.Field(SiteNode, source="soil_data__site", required=True) + depth_interval = graphene.Field(DepthInterval, required=True) + class Meta: model = DepthDependentSoilData exclude = [ @@ -48,8 +117,13 @@ class Meta: "created_at", "updated_at", "soil_data", + "depth_interval_start", + "depth_interval_end", ] + def resolve_depth_interval(self, info): + return DepthInterval(start=self.depth_interval_start, end=self.depth_interval_end) + @classmethod def texture_enum(cls): return cls._meta.fields["texture"].type() @@ -112,6 +186,81 @@ class DepthIntervalInput(graphene.InputObjectType): end = graphene.Int(required=True) +class SoilDataUpdateDepthIntervalMutation(BaseWriteMutation): + soil_data = graphene.Field(SoilDataNode) + model_class = SoilDataDepthIntervalNode + + class Input: + site_id = graphene.ID(required=True) + label = graphene.String() + depth_interval = graphene.Field(DepthIntervalInput, required=True) + slope_enabled = graphene.Boolean() + soil_texture_enabled = graphene.Boolean() + soil_color_enabled = graphene.Boolean() + vertical_cracking_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() + land_use_land_cover_enabled = graphene.Boolean() + soil_limitations_enabled = graphene.Boolean() + + @classmethod + def mutate_and_get_payload(cls, root, info, site_id, depth_interval, **kwargs): + site = cls.get_or_throw(Site, "id", site_id) + + user = info.context.user + if not user.has_perm(Site.get_perm("change"), site): + raise cls.not_allowed(MutationTypes.UPDATE) + + with transaction.atomic(): + if not hasattr(site, "soil_data"): + site.soil_data = SoilData() + site.soil_data.save() + + kwargs["model_instance"], _ = site.soil_data.depth_intervals.get_or_create( + depth_interval_start=depth_interval["start"], + depth_interval_end=depth_interval["end"], + ) + + super().mutate_and_get_payload(root, info, **kwargs) + + return SoilDataUpdateDepthIntervalMutation({"soil_data": site.soil_data}) + + +class SoilDataDeleteDepthIntervalMutation(BaseAuthenticatedMutation): + soil_data = graphene.Field(SoilDataNode) + + class Input: + site_id = graphene.ID(required=True) + depth_interval = graphene.Field(DepthIntervalInput, required=True) + + @classmethod + def mutate_and_get_payload(cls, root, info, site_id, depth_interval, **kwargs): + site = cls.get_or_throw(Site, "id", site_id) + + user = info.context.user + if not user.has_perm(Site.get_perm("delete"), site): + raise cls.not_allowed(MutationTypes.DELETE) + + if not hasattr(site, "soil_data"): + cls.not_found() + + try: + depth_interval, _ = site.soil_data.depth_intervals.get( + depth_interval_start=depth_interval["start"], + depth_interval_end=depth_interval["end"], + ) + except SoilDataDepthInterval.DoesNotExist: + cls.not_found() + + depth_interval.delete() + + return SoilDataDeleteDepthIntervalMutation({"soil_data": site.soil_data}) + + class SoilDataUpdateMutation(BaseWriteMutation): soil_data = graphene.Field(SoilDataNode) model_class = SoilData @@ -126,7 +275,6 @@ class Input: slope_steepness_select = SoilDataNode.slope_steepness_enum() slope_steepness_percent = graphene.Int() slope_steepness_degree = graphene.Int() - depth_intervals = graphene.List(graphene.NonNull(DepthIntervalInput)) @classmethod def mutate_and_get_payload(cls, root, info, site_id, **kwargs): @@ -145,13 +293,12 @@ def mutate_and_get_payload(cls, root, info, site_id, **kwargs): class DepthDependentSoilDataUpdateMutation(BaseWriteMutation): - depth_dependent_soil_data = graphene.Field(DepthDependentSoilDataNode) + soil_data = graphene.Field(SoilDataNode) model_class = DepthDependentSoilData class Input: site_id = graphene.ID(required=True) - depth_start = graphene.Int(required=True) - depth_end = graphene.Int(required=True) + depth_interval = graphene.Field(DepthIntervalInput, required=True) texture = DepthDependentSoilDataNode.texture_enum() rock_fragment_volume = DepthDependentSoilDataNode.rock_fragment_volume_enum() color_hue_substep = DepthDependentSoilDataNode.color_hue_substep_enum() @@ -173,7 +320,7 @@ class Input: carbonates = DepthDependentSoilDataNode.carbonates_enum() @classmethod - def mutate_and_get_payload(cls, root, info, site_id, depth_start, depth_end, **kwargs): + def mutate_and_get_payload(cls, root, info, site_id, depth_interval, **kwargs): site = cls.get_or_throw(Site, "id", site_id) user = info.context.user @@ -186,7 +333,114 @@ def mutate_and_get_payload(cls, root, info, site_id, depth_start, depth_end, **k site.soil_data.save() kwargs["model_instance"], _ = site.soil_data.depth_dependent_data.get_or_create( - depth_start=depth_start, depth_end=depth_end + depth_start=depth_interval["start"], depth_end=depth_interval["end"] + ) + + super().mutate_and_get_payload(root, info, **kwargs) + + return DepthDependentSoilDataUpdateMutation({"soil_data": site.soil_data}) + + +class ProjectSoilSettingsUpdateMutation(BaseWriteMutation): + soil_settings = graphene.Field(ProjectSoilSettingsNode) + model_class = ProjectSoilSettings + + class Input: + project_id = graphene.ID(required=True) + measurement_units = ProjectSoilSettingsNode.measurement_units_enum() + depth_interval_preset = ProjectSoilSettingsNode.depth_interval_preset_enum() + soil_pit_required = graphene.Boolean() + slope_required = graphene.Boolean() + soil_texture_required = graphene.Boolean() + soil_color_required = graphene.Boolean() + vertical_cracking_required = graphene.Boolean() + carbonates_required = graphene.Boolean() + ph_required = graphene.Boolean() + soil_organic_carbon_matter_required = graphene.Boolean() + electrical_conductivity_required = graphene.Boolean() + sodium_adsorption_ratio_required = graphene.Boolean() + soil_structure_required = graphene.Boolean() + land_use_land_cover_required = graphene.Boolean() + soil_limitations_required = graphene.Boolean() + photos_required = graphene.Boolean() + notes_required = graphene.Boolean() + + @classmethod + def mutate_and_get_payload(cls, root, info, project_id, **kwargs): + project = cls.get_or_throw(Project, "id", project_id) + + user = info.context.user + if not user.has_perm(Project.get_perm("change"), project): + raise cls.not_allowed(MutationTypes.UPDATE) + + if not hasattr(project, "soil_settings"): + project.soil_settings = ProjectSoilSettings() + + kwargs["model_instance"] = project.soil_settings + + return super().mutate_and_get_payload(root, info, **kwargs) + + +class ProjectSoilSettingsUpdateDepthIntervalMutation(BaseWriteMutation): + soil_settings = graphene.Field(ProjectSoilSettingsNode) + model_class = ProjectDepthInterval + + class Input: + project_id = graphene.ID(required=True) + label = graphene.String() + depth_interval = graphene.Field(DepthIntervalInput, required=True) + + @classmethod + def mutate_and_get_payload(cls, root, info, project_id, depth_interval, **kwargs): + project = cls.get_or_throw(Project, "id", project_id) + + user = info.context.user + if not user.has_perm(Project.get_perm("change"), project): + raise cls.not_allowed(MutationTypes.UPDATE) + + with transaction.atomic(): + if not hasattr(project, "soil_settings"): + project.soil_settings = ProjectSoilSettings() + project.soil_settings.save() + + kwargs["model_instance"], _ = project.soil_settings.depth_intervals.get_or_create( + depth_interval_start=depth_interval["start"], + depth_interval_end=depth_interval["end"], + ) + + super().mutate_and_get_payload(root, info, **kwargs) + return ProjectSoilSettingsUpdateDepthIntervalMutation( + {"soil_settings": project.soil_settings} + ) + + +class ProjectSoilSettingsDeleteDepthIntervalMutation(BaseAuthenticatedMutation): + soil_settings = graphene.Field(ProjectSoilSettingsNode) + + class Input: + project_id = graphene.ID(required=True) + depth_interval = graphene.Field(DepthIntervalInput, required=True) + + @classmethod + def mutate_and_get_payload(cls, root, info, project_id, depth_interval, **kwargs): + project = cls.get_or_throw(Project, "id", project_id) + + user = info.context.user + if not user.has_perm(Project.get_perm("delete"), project): + raise cls.not_allowed(MutationTypes.DELETE) + + if not hasattr(project, "soil_settings"): + cls.not_found() + + try: + depth_interval, _ = project.soil_settings.depth_intervals.get( + depth_interval_start=depth_interval["start"], + depth_interval_end=depth_interval["end"], ) + except ProjectDepthInterval.DoesNotExist: + cls.not_found() - return super().mutate_and_get_payload(root, info, **kwargs) + depth_interval.delete() + return ProjectSoilSettingsDeleteDepthIntervalMutation( + {"soil_setting": project.soil_settings} + ) diff --git a/terraso_backend/apps/soil_id/migrations/0005_projectdepthinterval_projectsoilsettings_and_more.py b/terraso_backend/apps/soil_id/migrations/0005_projectdepthinterval_projectsoilsettings_and_more.py new file mode 100644 index 000000000..673b6b6ef --- /dev/null +++ b/terraso_backend/apps/soil_id/migrations/0005_projectdepthinterval_projectsoilsettings_and_more.py @@ -0,0 +1,263 @@ +# Generated by Django 4.2.5 on 2023-09-27 23:15 + +import uuid + +import django.core.validators +import django.db.models.deletion +import rules.contrib.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("project_management", "0015_project_seen_by_site_seen_by"), + ("soil_id", "0004_soildata_depth_intervals"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectDepthInterval", + 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)), + ("label", models.CharField(blank=True, max_length=10)), + ("depth_interval_start", models.PositiveIntegerField(blank=True)), + ( + "depth_interval_end", + models.PositiveIntegerField( + blank=True, validators=[django.core.validators.MaxValueValidator(200)] + ), + ), + ], + options={ + "ordering": ["created_at"], + "get_latest_by": "-created_at", + "abstract": False, + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="ProjectSoilSettings", + 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)), + ( + "measurement_units", + models.CharField( + blank=True, + choices=[("IMPERIAL", "Imperial"), ("METRIC", "Metric")], + null=True, + ), + ), + ("soil_pit_required", models.BooleanField(blank=True, default=False)), + ( + "depth_interval_preset", + models.CharField( + choices=[ + ("LANDPKS", "Landpks"), + ("NRCS", "Nrcs"), + ("NONE", "None"), + ("CUSTOM", "Custom"), + ], + default="LANDPKS", + ), + ), + ("slope_required", models.BooleanField(blank=True, default=False)), + ("soil_texture_required", models.BooleanField(blank=True, default=False)), + ("soil_color_required", models.BooleanField(blank=True, default=False)), + ("vertical_cracking_required", models.BooleanField(blank=True, default=False)), + ("carbonates_required", models.BooleanField(blank=True, default=False)), + ("ph_required", models.BooleanField(blank=True, default=False)), + ( + "soil_organic_carbon_matter_required", + models.BooleanField(blank=True, default=False), + ), + ( + "electrical_conductivity_required", + models.BooleanField(blank=True, default=False), + ), + ( + "sodium_adsorption_ratio_required", + models.BooleanField(blank=True, default=False), + ), + ("soil_structure_required", models.BooleanField(blank=True, default=False)), + ("land_use_land_cover_required", models.BooleanField(blank=True, default=False)), + ("soil_limitations_required", models.BooleanField(blank=True, default=False)), + ("photos_required", models.BooleanField(blank=True, default=False)), + ("notes_required", models.BooleanField(blank=True, default=False)), + ], + options={ + "ordering": ["created_at"], + "get_latest_by": "-created_at", + "abstract": False, + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.CreateModel( + name="SoilDataDepthInterval", + 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)), + ("label", models.CharField(blank=True, max_length=10)), + ("depth_interval_start", models.PositiveIntegerField(blank=True)), + ( + "depth_interval_end", + models.PositiveIntegerField( + blank=True, validators=[django.core.validators.MaxValueValidator(200)] + ), + ), + ("slope_enabled", models.BooleanField(blank=True, default=False)), + ("soil_texture_enabled", models.BooleanField(blank=True, default=False)), + ("soil_color_enabled", models.BooleanField(blank=True, default=False)), + ("vertical_cracking_enabled", models.BooleanField(blank=True, default=False)), + ("carbonates_enabled", models.BooleanField(blank=True, default=False)), + ("ph_enabled", models.BooleanField(blank=True, default=False)), + ( + "soil_organic_carbon_matter_enabled", + models.BooleanField(blank=True, default=False), + ), + ("electrical_conductivity_enabled", models.BooleanField(blank=True, default=False)), + ("sodium_adsorption_ratio_enabled", models.BooleanField(blank=True, default=False)), + ("soil_structure_enabled", models.BooleanField(blank=True, default=False)), + ("land_use_land_cover_enabled", models.BooleanField(blank=True, default=False)), + ("soil_limitations_enabled", models.BooleanField(blank=True, default=False)), + ], + options={ + "ordering": ["created_at"], + "get_latest_by": "-created_at", + "abstract": False, + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.AlterModelOptions( + name="depthdependentsoildata", + options={"get_latest_by": "-created_at", "ordering": ["created_at"]}, + ), + migrations.RemoveConstraint( + model_name="depthdependentsoildata", + name="unique_depth_interval", + ), + migrations.RemoveConstraint( + model_name="depthdependentsoildata", + name="depth_interval_coherence", + ), + migrations.RenameField( + model_name="depthdependentsoildata", + old_name="depth_start", + new_name="depth_interval_start", + ), + migrations.RenameField( + model_name="depthdependentsoildata", + old_name="depth_end", + new_name="depth_interval_end", + ), + migrations.RemoveField( + model_name="soildata", + name="depth_intervals", + ), + migrations.AddConstraint( + model_name="depthdependentsoildata", + constraint=models.UniqueConstraint( + fields=("soil_data", "depth_interval_start", "depth_interval_end"), + name="soil_id_depthdependentsoildata_unique_depth_interval", + ), + ), + migrations.AddConstraint( + model_name="depthdependentsoildata", + constraint=models.CheckConstraint( + check=models.Q(("depth_interval_start__lt", models.F("depth_interval_end"))), + name="soil_id_depthdependentsoildata_depth_interval_coherence", + ), + ), + migrations.AddField( + model_name="soildatadepthinterval", + name="soil_data", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="depth_intervals", + to="soil_id.soildata", + ), + ), + migrations.AddField( + model_name="projectsoilsettings", + name="project", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="soil_settings", + to="project_management.project", + ), + ), + migrations.AddField( + model_name="projectdepthinterval", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="depth_intervals", + to="soil_id.projectsoilsettings", + ), + ), + migrations.AddConstraint( + model_name="soildatadepthinterval", + constraint=models.UniqueConstraint( + fields=("soil_data", "depth_interval_start", "depth_interval_end"), + name="soil_id_soildatadepthinterval_unique_depth_interval", + ), + ), + migrations.AddConstraint( + model_name="soildatadepthinterval", + constraint=models.CheckConstraint( + check=models.Q(("depth_interval_start__lt", models.F("depth_interval_end"))), + name="soil_id_soildatadepthinterval_depth_interval_coherence", + ), + ), + migrations.AddConstraint( + model_name="projectdepthinterval", + constraint=models.UniqueConstraint( + fields=("project", "depth_interval_start", "depth_interval_end"), + name="soil_id_projectdepthinterval_unique_depth_interval", + ), + ), + migrations.AddConstraint( + model_name="projectdepthinterval", + constraint=models.CheckConstraint( + check=models.Q(("depth_interval_start__lt", models.F("depth_interval_end"))), + name="soil_id_projectdepthinterval_depth_interval_coherence", + ), + ), + migrations.AlterField( + model_name="depthdependentsoildata", + name="depth_interval_end", + field=models.PositiveIntegerField( + blank=True, validators=[django.core.validators.MaxValueValidator(200)] + ), + ), + migrations.AlterField( + model_name="depthdependentsoildata", + name="depth_interval_start", + field=models.PositiveIntegerField(blank=True), + ), + ] diff --git a/terraso_backend/apps/soil_id/models/__init__.py b/terraso_backend/apps/soil_id/models/__init__.py index efb05dd33..0be173ee0 100644 --- a/terraso_backend/apps/soil_id/models/__init__.py +++ b/terraso_backend/apps/soil_id/models/__init__.py @@ -15,6 +15,13 @@ from .depth_dependent_soil_data import DepthDependentSoilData -from .soil_data import SoilData +from .project_soil_settings import ProjectDepthInterval, ProjectSoilSettings +from .soil_data import SoilData, SoilDataDepthInterval -__all__ = ["SoilData", "DepthDependentSoilData"] +__all__ = [ + "SoilData", + "DepthDependentSoilData", + "ProjectSoilSettings", + "ProjectDepthInterval", + "SoilDataDepthInterval", +] diff --git a/terraso_backend/apps/soil_id/models/data_input_groups.py b/terraso_backend/apps/soil_id/models/data_input_groups.py new file mode 100644 index 000000000..e69de29bb diff --git a/terraso_backend/apps/soil_id/models/depth_dependent_soil_data.py b/terraso_backend/apps/soil_id/models/depth_dependent_soil_data.py index 8b452dcf7..a6e88bd30 100644 --- a/terraso_backend/apps/soil_id/models/depth_dependent_soil_data.py +++ b/terraso_backend/apps/soil_id/models/depth_dependent_soil_data.py @@ -17,26 +17,17 @@ from django.db import models from apps.core.models.commons import BaseModel +from apps.soil_id.models.depth_interval import BaseDepthInterval from apps.soil_id.models.soil_data import SoilData -class DepthDependentSoilData(BaseModel): - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["soil_data", "depth_start", "depth_end"], name="unique_depth_interval" - ), - models.CheckConstraint( - check=models.Q(depth_start__lt=models.F("depth_end")), - name="depth_interval_coherence", - ), - ] - +class DepthDependentSoilData(BaseModel, BaseDepthInterval): soil_data = models.ForeignKey( SoilData, on_delete=models.CASCADE, related_name="depth_dependent_data" ) - depth_start = models.PositiveIntegerField(validators=[MaxValueValidator(200)]) - depth_end = models.PositiveIntegerField(validators=[MaxValueValidator(200)]) + + class Meta(BaseModel.Meta): + constraints = BaseDepthInterval.constraints("soil_data") class Texture(models.TextChoices): SAND = "SAND" diff --git a/terraso_backend/apps/soil_id/models/depth_interval.py b/terraso_backend/apps/soil_id/models/depth_interval.py new file mode 100644 index 000000000..d15e0d05b --- /dev/null +++ b/terraso_backend/apps/soil_id/models/depth_interval.py @@ -0,0 +1,43 @@ +from typing import List, Self + +from django.core.validators import MaxValueValidator +from django.db import models +from django.forms import ValidationError + + +class BaseDepthInterval(models.Model): + depth_interval_start = models.PositiveIntegerField(blank=True) + depth_interval_end = models.PositiveIntegerField( + blank=True, validators=[MaxValueValidator(200)] + ) + + class Meta: + abstract = True + + @staticmethod + def constraints(related_field: str): + return [ + models.UniqueConstraint( + fields=[related_field, "depth_interval_start", "depth_interval_end"], + name="%(app_label)s_%(class)s_unique_depth_interval", + ), + models.CheckConstraint( + check=models.Q(depth_interval_start__lt=models.F("depth_interval_end")), + name="%(app_label)s_%(class)s_depth_interval_coherence", + ), + ] + + @staticmethod + def validate_intervals(intervals: List[Self]): + intervals.sort(key=lambda interval: interval.start) + for index, interval in enumerate(intervals): + if ( + index + 1 < len(intervals) + and interval.depth_interval_end > intervals[index + 1].depth_interval_start + ): + raise ValidationError( + f""" + Depth interval must end at or before next interval, + got {interval} followed by {intervals[index + 1]} + """ + ) diff --git a/terraso_backend/apps/soil_id/models/project_soil_settings.py b/terraso_backend/apps/soil_id/models/project_soil_settings.py new file mode 100644 index 000000000..92ff4d70a --- /dev/null +++ b/terraso_backend/apps/soil_id/models/project_soil_settings.py @@ -0,0 +1,57 @@ +from django.db import models + +from apps.core.models.commons import BaseModel +from apps.project_management.models.projects import Project +from apps.soil_id.models.depth_interval import BaseDepthInterval + + +class ProjectSoilSettings(BaseModel): + project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="soil_settings") + + class MeasurementUnit(models.TextChoices): + IMPERIAL = "IMPERIAL" + METRIC = "METRIC" + + measurement_units = models.CharField(blank=True, null=True, choices=MeasurementUnit.choices) + + class DepthIntervalPreset(models.TextChoices): + LANDPKS = "LANDPKS" + NRCS = "NRCS" + NONE = "NONE" + CUSTOM = "CUSTOM" + + depth_interval_preset = models.CharField( + null=False, + default=DepthIntervalPreset.LANDPKS, + choices=DepthIntervalPreset.choices, + ) + + def clean(self): + super().clean() + BaseDepthInterval.validate_intervals(list(self.depth_intervals)) + + soil_pit_required = models.BooleanField(blank=True, default=False) + slope_required = models.BooleanField(blank=True, default=False) + soil_texture_required = models.BooleanField(blank=True, default=False) + soil_color_required = models.BooleanField(blank=True, default=False) + vertical_cracking_required = models.BooleanField(blank=True, default=False) + carbonates_required = models.BooleanField(blank=True, default=False) + ph_required = models.BooleanField(blank=True, default=False) + soil_organic_carbon_matter_required = models.BooleanField(blank=True, default=False) + electrical_conductivity_required = models.BooleanField(blank=True, default=False) + sodium_adsorption_ratio_required = models.BooleanField(blank=True, default=False) + soil_structure_required = models.BooleanField(blank=True, default=False) + land_use_land_cover_required = models.BooleanField(blank=True, default=False) + soil_limitations_required = models.BooleanField(blank=True, default=False) + photos_required = models.BooleanField(blank=True, default=False) + notes_required = models.BooleanField(blank=True, default=False) + + +class ProjectDepthInterval(BaseModel, BaseDepthInterval): + project = models.ForeignKey( + ProjectSoilSettings, on_delete=models.CASCADE, related_name="depth_intervals" + ) + label = models.CharField(blank=True, max_length=10) + + class Meta(BaseModel.Meta): + constraints = BaseDepthInterval.constraints("project") diff --git a/terraso_backend/apps/soil_id/models/soil_data.py b/terraso_backend/apps/soil_id/models/soil_data.py index 2db728379..efb034b60 100644 --- a/terraso_backend/apps/soil_id/models/soil_data.py +++ b/terraso_backend/apps/soil_id/models/soil_data.py @@ -19,6 +19,7 @@ from apps.core.models.commons import BaseModel from apps.project_management.models.sites import Site +from apps.soil_id.models.depth_interval import BaseDepthInterval def default_depth_intervals(): @@ -105,6 +106,25 @@ class SlopeSteepness(models.TextChoices): blank=True, null=True, validators=[MinValueValidator(0), MaxValueValidator(90)] ) - depth_intervals = models.JSONField( - blank=True, validators=[validate_depth_intervals], default=default_depth_intervals + +class SoilDataDepthInterval(BaseModel, BaseDepthInterval): + soil_data = models.ForeignKey( + SoilData, on_delete=models.CASCADE, related_name="depth_intervals" ) + label = models.CharField(blank=True, max_length=10) + + class Meta(BaseModel.Meta): + constraints = BaseDepthInterval.constraints("soil_data") + + slope_enabled = models.BooleanField(blank=True, default=False) + soil_texture_enabled = models.BooleanField(blank=True, default=False) + soil_color_enabled = models.BooleanField(blank=True, default=False) + vertical_cracking_enabled = models.BooleanField(blank=True, default=False) + carbonates_enabled = models.BooleanField(blank=True, default=False) + ph_enabled = models.BooleanField(blank=True, default=False) + soil_organic_carbon_matter_enabled = models.BooleanField(blank=True, default=False) + electrical_conductivity_enabled = models.BooleanField(blank=True, default=False) + sodium_adsorption_ratio_enabled = models.BooleanField(blank=True, default=False) + soil_structure_enabled = models.BooleanField(blank=True, default=False) + land_use_land_cover_enabled = models.BooleanField(blank=True, default=False) + soil_limitations_enabled = models.BooleanField(blank=True, default=False) diff --git a/terraso_backend/tests/graphql/mutations/test_soil_data.py b/terraso_backend/tests/graphql/mutations/test_soil_data.py index 18867aecb..e6e02496b 100644 --- a/terraso_backend/tests/graphql/mutations/test_soil_data.py +++ b/terraso_backend/tests/graphql/mutations/test_soil_data.py @@ -24,10 +24,6 @@ slopeSteepnessSelect slopeSteepnessPercent slopeSteepnessDegree - depthIntervals { - start - end - } } errors } @@ -94,7 +90,6 @@ def test_update_soil_data_constraints(client, user, site): variables={"input": {"siteId": str(site.id), attr: value}}, client=client, ) - logger.info(response.json()) if msg is None: assert not hasattr(response.json(), "data") and response.json()["errors"] is not None else: @@ -188,8 +183,10 @@ def test_update_depth_intervals(client, user, site): ) { updateDepthDependentSoilData(input: $input) { depthDependentSoilData { - depthStart - depthEnd + depthInterval { + start + end + } texture rockFragmentVolume colorHueSubstep @@ -220,8 +217,10 @@ def test_update_depth_dependent_soil_data(client, user, site): client.force_login(user) new_data = { "siteId": str(site.id), - "depthStart": 0, - "depthEnd": 10, + "depthInterval": { + "start": 0, + "end": 10, + }, "texture": "CLAY", "rockFragmentVolume": "VOLUME_0_1", "colorHueSubstep": "SUBSTEP_2_5", @@ -250,11 +249,12 @@ def test_update_depth_dependent_soil_data(client, user, site): new_data.pop("siteId") for attr, value in new_data.items(): assert payload[attr] == value - new_data.pop("depthStart") - new_data.pop("depthEnd") + new_data.pop("depthInterval") cleared_data = dict( - {k: None for k in new_data.keys()}, siteId=str(site.id), depthStart=0, depthEnd=10 + {k: None for k in new_data.keys()}, + siteId=str(site.id), + depthInterval={"start": 0, "end": 10}, ) response = graphql_query( UPDATE_DEPTH_DEPENDENT_QUERY, variables={"input": cleared_data}, client=client @@ -264,11 +264,10 @@ def test_update_depth_dependent_soil_data(client, user, site): for attr in new_data.keys(): assert payload[attr] is None - partial_data = {"siteId": str(site.id), "depthStart": 0, "depthEnd": 10} + partial_data = {"siteId": str(site.id), "depthInterval": {"start": 0, "end": 10}} response = graphql_query( UPDATE_DEPTH_DEPENDENT_QUERY, variables={"input": partial_data}, client=client ) - logger.info(response.json()) payload = response.json()["data"]["updateDepthDependentSoilData"]["depthDependentSoilData"] assert response.json()["data"]["updateDepthDependentSoilData"]["errors"] is None for attr in new_data.keys(): @@ -310,11 +309,14 @@ def test_update_depth_dependent_soil_data_constraints(client, user, site): response = graphql_query( UPDATE_DEPTH_DEPENDENT_QUERY, variables={ - "input": {"siteId": str(site.id), "depthStart": 0, "depthEnd": 10, attr: value} + "input": { + "siteId": str(site.id), + "depthInterval": {"start": 0, "end": 10}, + attr: value, + } }, client=client, ) - logger.info(response.json()) if msg is None: assert not hasattr(response.json(), "data") and response.json()["errors"] is not None else: @@ -330,8 +332,7 @@ def test_update_depth_dependent_soil_data_not_allowed(client, site): client.force_login(user) new_data = { "siteId": str(site.id), - "depthStart": 0, - "depthEnd": 10, + "depthInterval": {"start": 0, "end": 10}, "texture": "CLAY", } response = graphql_query(