diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index 075e6a62e..0f4a040da 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -2352,7 +2352,7 @@ type SoilDataBulkUpdateResultEntry { union SoilDataBulkUpdateResult = SoilDataBulkUpdateSuccess | SoilDataBulkUpdateFailure type SoilDataBulkUpdateSuccess { - soilData: SoilDataNode! + site: SiteNode! } type SoilDataBulkUpdateFailure { @@ -2360,8 +2360,8 @@ type SoilDataBulkUpdateFailure { } enum SoilDataBulkUpdateFailureReason { - SITE_DELETED - LOST_PERMISSION + DOES_NOT_EXIST + NOT_ALLOWED } input SoilDataBulkUpdateInput { @@ -2388,7 +2388,28 @@ input SoilDataBulkUpdateEntry { landCoverSelect: SoilIdSoilDataLandCoverSelectChoices grazingSelect: SoilIdSoilDataGrazingSelectChoices siteId: ID! - depthIntervals: [SoilDataBulkUpdateDepthDependentEntry!]! + soilData: SoilDataBulkUpdateSoilData! + depthDependentData: [SoilDataBulkUpdateDepthDependentEntry!]! +} + +input SoilDataBulkUpdateSoilData { + 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 } input SoilDataBulkUpdateDepthDependentEntry { 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 index 200e460e2..66bcc318e 100644 --- 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 @@ -6,21 +6,19 @@ from django.db import transaction 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 import ( - SoilDataDepthDependentInputs, - SoilDataInputs, - SoilDataNode, -) +from apps.soil_id.graphql.soil_data import SoilDataDepthDependentInputs, SoilDataInputs 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 SoilDataBulkUpdateSuccess(graphene.ObjectType): - soil_data = graphene.Field(SoilDataNode, required=True) + site = graphene.Field(SiteNode, required=True) # TODO: just a generic "can't access" result? @@ -47,9 +45,14 @@ class SoilDataBulkUpdateDepthDependentEntry(SoilDataDepthDependentInputs, graphe pass +class SoilDataBulkUpdateSoilData(SoilDataInputs, graphene.InputObjectType): + pass + + class SoilDataBulkUpdateEntry(SoilDataInputs, graphene.InputObjectType): site_id = graphene.ID(required=True) - depth_intervals = graphene.Field( + soil_data = graphene.Field(graphene.NonNull(SoilDataBulkUpdateSoilData)) + depth_dependent_data = graphene.Field( graphene.List(graphene.NonNull(SoilDataBulkUpdateDepthDependentEntry)), required=True ) @@ -66,13 +69,14 @@ class Input: @classmethod def mutate_and_get_payload(cls, root, info, soil_data): + # TODO: refactor spaghetti mutation logic re: history, split into smaller functions try: results = [] with transaction.atomic(): for entry in soil_data: site_id = entry.pop("site_id") - depth_intervals = entry.pop("depth_intervals") + depth_intervals = entry["depth_dependent_data"] site = Site.objects.filter(id=site_id).first() @@ -91,7 +95,7 @@ def mutate_and_get_payload(cls, root, info, soil_data): if not check_site_permission(user, SiteAction.ENTER_DATA, Context(site=site)): results.append( SoilDataBulkUpdateResultEntry( - site_id=entry["site_id"], + site_id=site_id, result=SoilDataBulkUpdateFailure( reason=SoilDataBulkUpdateFailureReason.NOT_ALLOWED ), @@ -102,7 +106,7 @@ def mutate_and_get_payload(cls, root, info, soil_data): if not hasattr(site, "soil_data"): site.soil_data = SoilData() - for attr, value in entry.items(): + for attr, value in entry["soil_data"].items(): if isinstance(value, enum.Enum): value = value.value setattr(site.soil_data, attr, value) @@ -121,15 +125,22 @@ def mutate_and_get_payload(cls, root, info, soil_data): value = value.value setattr(depth_interval, attr, value) + depth_interval_input["depth_interval"] = interval + depth_interval.save() results.append( SoilDataBulkUpdateResultEntry( site_id=site_id, - result=SoilDataBulkUpdateSuccess(soil_data=site.soil_data), + result=SoilDataBulkUpdateSuccess(site=site), ) ) + history_entry = SoilDataHistory( + site=site, changed_by=user, soil_data_changes=entry + ) + history_entry.save() + logger.info(results) return cls(results=results) 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..5f18c9be5 --- /dev/null +++ b/terraso_backend/apps/soil_id/migrations/0019_soildatahistory.py @@ -0,0 +1,53 @@ +# Generated by Django 5.1.1 on 2024-10-01 20:04 + +import django.db.models.deletion +import rules.contrib.models +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("project_management", "0029_alter_projectsettings_options"), + ("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( + on_delete=django.db.models.deletion.CASCADE, to="project_management.site" + ), + ), + ], + 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..3f9fc15bb --- /dev/null +++ b/terraso_backend/apps/soil_id/models/soil_data_history.py @@ -0,0 +1,37 @@ +# 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, on_delete=models.CASCADE) + changed_by = models.ForeignKey(User, on_delete=models.CASCADE) + + # intended JSON schema: { + # ...soilDataInputs, + # "depth_intervals": [{ + # "depth_interval": { + # "start": number, + # "end": number + # }, + # ...depthIntervalInputs, + # }] + # } + soil_data_changes = models.JSONField() diff --git a/terraso_backend/tests/graphql/mutations/test_soil_data.py b/terraso_backend/tests/graphql/mutations/test_soil_data.py index 8f7022c39..e23b12558 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 @@ -793,14 +794,16 @@ def test_apply_to_all(client, project_site, project_manager): reason } ... on SoilDataBulkUpdateSuccess { - soilData { - slopeAspect - depthDependentData { - depthInterval { - start - end + site { + soilData { + slopeAspect + depthDependentData { + depthInterval { + start + end + } + clayPercent } - clayPercent } } } @@ -822,18 +825,20 @@ def test_bulk_update(client, user): "soilData": [ { "siteId": str(sites[0].id), - "slopeAspect": 10, - "depthIntervals": [], + "soilData": {"slopeAspect": 10}, + "depthDependentData": [], }, { "siteId": str(sites[1].id), - "depthIntervals": [ + "soilData": {}, + "depthDependentData": [ {"depthInterval": {"start": 0, "end": 10}, "clayPercent": 5} ], }, { "siteId": str("c9df7deb-6b9d-4c55-8ba6-641acc47dbb2"), - "depthIntervals": [], + "soilData": {}, + "depthDependentData": [], }, ] }, @@ -857,3 +862,9 @@ def test_bulk_update(client, user): .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["depth_dependent_data"][0]["clay_percent"] == 5