Repository: techmatters/terraso-backend

Issue:
Append to Project AuditLog on ProjectCreation and ProjectUpdate
- [ ] Add Log on Project creation
- [ ] Add Loc on Site creation
- [ ] Add log on Site update
Closes: #551 #553


Discussion:

Resolving patch:
diff --git a/terraso_backend/apps/graphql/schema/commons.py b/terraso_backend/apps/graphql/schema/commons.py
--- a/terraso_backend/apps/graphql/schema/commons.py
+++ b/terraso_backend/apps/graphql/schema/commons.py
@@ -15,6 +15,7 @@
 
 import json
 import re
+from typing import Optional
 
 import structlog
 from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
@@ -22,6 +23,8 @@
 from graphene import Connection, Field, Int, List, NonNull, ObjectType, String, relay
 from graphene.types.generic import GenericScalar
 
+from apps.audit_logs import api as audit_log_api
+from apps.audit_logs import services as audit_log_services
 from apps.core.formatters import from_camel_to_snake_case
 from apps.graphql.exceptions import (
     GraphQLNotAllowedException,
@@ -161,6 +164,7 @@ def not_allowed_create(cls, model, msg=None, extra=None):
 
 
 class BaseWriteMutation(BaseAuthenticatedMutation):
+    logger: Optional[audit_log_api.AuditLog] = None
     model_class = None
 
     @classmethod
@@ -233,6 +237,13 @@ def get_or_throw(cls, model, field_name, id_):
         except model.DoesNotExist:
             return GraphQLNotFoundException(field_name=field_name, model_name=model.__name__)
 
+    @classmethod
+    def get_logger(cls):
+        """Returns the logger instance."""
+        if not cls.logger:
+            cls.logger = audit_log_services.new_audit_logger()
+        return cls.logger
+
 
 class BaseDeleteMutation(BaseAuthenticatedMutation):
     model_class = None
diff --git a/terraso_backend/apps/graphql/schema/projects.py b/terraso_backend/apps/graphql/schema/projects.py
--- a/terraso_backend/apps/graphql/schema/projects.py
+++ b/terraso_backend/apps/graphql/schema/projects.py
@@ -13,11 +13,14 @@
 # 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 datetime import datetime
+
 import graphene
 from django.db import transaction
 from graphene import relay
 from graphene_django import DjangoObjectType
 
+from apps.audit_logs import api as log_api
 from apps.project_management.models import Project
 
 from .commons import BaseWriteMutation, TerrasoConnection
@@ -52,10 +55,25 @@ class Input:
 
     @classmethod
     def mutate_and_get_payload(cls, root, info, **kwargs):
+        logger = cls.get_logger()
+        user = info.context.user
         with transaction.atomic():
             group = Project.create_default_group(name=kwargs["name"])
             kwargs["group"] = group
             kwargs["privacy"] = kwargs["privacy"].value
             result = super().mutate_and_get_payload(root, info, **kwargs)
-            result.project.add_manager(info.context.user)
+            result.project.add_manager(user)
+
+        client_time = kwargs.get("client_time", None)
+        if not client_time:
+            client_time = datetime.now()
+        action = log_api.CREATE
+        metadata = {"name": kwargs["name"], "privacy": kwargs["privacy"]}
+        logger.log(
+            user=user,
+            action=action,
+            resource=result.project,
+            client_time=client_time,
+            metadata=metadata
+        )
         return result
diff --git a/terraso_backend/apps/graphql/schema/sites.py b/terraso_backend/apps/graphql/schema/sites.py
--- a/terraso_backend/apps/graphql/schema/sites.py
+++ b/terraso_backend/apps/graphql/schema/sites.py
@@ -12,11 +12,14 @@
 #
 # 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 datetime import datetime
+
 import django_filters
 import graphene
 from graphene import relay
 from graphene_django import DjangoObjectType
 
+from apps.audit_logs import api as audit_log_api
 from apps.project_management.models import Project, Site
 
 from .commons import BaseWriteMutation, TerrasoConnection
@@ -62,10 +65,15 @@ class Input:
 
     @classmethod
     def mutate_and_get_payload(cls, root, info, **kwargs):
+        log = cls.get_logger()
         user = info.context.user
         if not cls.is_update(kwargs):
             kwargs["created_by"] = user
 
+        client_time = kwargs.pop("client_time", None)
+        if not client_time:
+            client_time = datetime.now()
+
         adding_to_project = "project_id" in kwargs
         if adding_to_project:
             project = cls.get_or_throw(Project, "project_id", kwargs["project_id"])
@@ -74,7 +82,27 @@ def mutate_and_get_payload(cls, root, info, **kwargs):
             kwargs["project"] = project
         else:
             kwargs["owner"] = info.context.user
-        return super().mutate_and_get_payload(root, info, **kwargs)
+
+        result = super().mutate_and_get_payload(root, info, **kwargs)
+        if result.errors:
+            return result
+
+        site = result.site
+        metadata = {
+            "latitude": kwargs["latitude"],
+            "longitude": kwargs["longitude"],
+            "name": kwargs["name"],
+        }
+        if kwargs.get("project_id", None):
+            metadata["project_id"] = kwargs["project_id"]
+        log.log(
+            user=user,
+            action=audit_log_api.CREATE,
+            resource=site,
+            metadata=metadata,
+            client_time=client_time
+        )
+        return result
 
 
 class SiteEditMutation(BaseWriteMutation):
@@ -92,6 +120,7 @@ class Input:
 
     @classmethod
     def mutate_and_get_payload(cls, root, info, **kwargs):
+        log = cls.get_logger()
         user = info.context.user
         site = cls.get_or_throw(Site, "id", kwargs["id"])
         if not user.has_perm(Site.get_perm("change"), site):
@@ -105,4 +134,25 @@ def mutate_and_get_payload(cls, root, info, **kwargs):
         if not user.has_perm(Project.get_perm("add_site"), project):
             raise cls.not_allowed(MutationTypes.UPDATE)
         site.add_to_project(project)
+
+        client_time = kwargs.get("client_time", None)
+        if not client_time:
+            client_time = datetime.now()
+
+        metadata = {}
+        for key, value in kwargs.items():
+            if key == "id":
+                continue
+            metadata[key] = value
+        if project_id:
+            metadata["project_name"] = project.name
+
+        log.log(
+            user=user,
+            action=audit_log_api.CHANGE,
+            resource=site,
+            metadata=metadata,
+            client_time=client_time
+        )
+
         return result


Test patch:
diff --git a/terraso_backend/tests/graphql/mutations/test_projects.py b/terraso_backend/tests/graphql/mutations/test_projects.py
--- a/terraso_backend/tests/graphql/mutations/test_projects.py
+++ b/terraso_backend/tests/graphql/mutations/test_projects.py
@@ -3,6 +3,8 @@
 import pytest
 from graphene_django.utils.testing import graphql_query
 
+from apps.audit_logs.api import CREATE
+from apps.audit_logs.models import Log
 from apps.project_management.models import Project
 
 pytestmark = pytest.mark.django_db
@@ -29,6 +31,14 @@ def test_create_project(client, user):
     project = Project.objects.get(pk=id)
     assert list(project.managers.all()) == [user]
 
+    logs = Log.objects.all()
+    assert len(logs) == 1
+    log_result = logs[0]
+    assert log_result.event == CREATE.value
+    assert log_result.resource_object == project
+    expected_metadata = {'name': 'testProject', 'privacy': 'private'}
+    assert log_result.metadata == expected_metadata
+
 
 def test_add_user_to_project(client, project, project_manager, user):
     client.force_login(project_manager)
diff --git a/terraso_backend/tests/graphql/mutations/test_sites.py b/terraso_backend/tests/graphql/mutations/test_sites.py
--- a/terraso_backend/tests/graphql/mutations/test_sites.py
+++ b/terraso_backend/tests/graphql/mutations/test_sites.py
@@ -18,6 +18,8 @@
 from graphene_django.utils.testing import graphql_query
 from mixer.backend.django import mixer
 
+from apps.audit_logs.api import CHANGE, CREATE
+from apps.audit_logs.models import Log
 from apps.core.models import User
 from apps.project_management.models import Project, Site
 
@@ -54,6 +56,14 @@ def test_site_creation(client_query, user):
     assert site.longitude == pytest.approx(site.longitude)
     assert site.owner == user
     assert site.created_by == user
+    logs = Log.objects.all()
+    assert len(logs) == 1
+    log_result = logs[0]
+    assert log_result.event == CREATE.value
+    assert log_result.user == user
+    assert log_result.resource_object == site
+    expected_metadata = {'name': 'Test Site', 'latitude': 0.0, 'longitude': 0.0}
+    assert log_result.metadata == expected_metadata
 
 
 def test_site_creation_in_project(client, project_manager, project):
@@ -71,6 +81,15 @@ def test_site_creation_in_project(client, project_manager, project):
     site = Site.objects.get(pk=id)
     assert site.project == project
     assert site.created_by == project_manager
+    logs = Log.objects.all()
+    assert len(logs) == 1
+    log_result = logs[0]
+    assert log_result.event == CREATE.value
+    assert log_result.resource_object == site
+    expected_metadata = {'name': 'Test Site', 'latitude': 0.0, 'longitude': 0.0}
+    assert log_result.metadata["name"] == expected_metadata["name"]
+    assert log_result.metadata["latitude"] == expected_metadata["latitude"]
+    assert log_result.metadata["longitude"] == expected_metadata["longitude"]
 
 
 EDIT_SITE_QUERY = """
@@ -109,6 +128,13 @@ def test_edit_site_in_project(client, project, project_manager, site):
     site.refresh_from_db()
     assert site.project.id == project.id
 
+    logs = Log.objects.all()
+    assert len(logs) == 1
+    log_result = logs[0]
+    assert log_result.event == CHANGE.value
+    assert log_result.resource_object == site
+    assert log_result.metadata["project_name"] == project.name
+
 
 def test_adding_site_to_project_user_not_manager(client, project, site, user):
     site_creator = mixer.blend(User)