diff --git a/terraso_backend/apps/graphql/schema/memberships.py b/terraso_backend/apps/graphql/schema/memberships.py index ff4ee3fc9..87b4508a8 100644 --- a/terraso_backend/apps/graphql/schema/memberships.py +++ b/terraso_backend/apps/graphql/schema/memberships.py @@ -24,6 +24,7 @@ from apps.graphql.exceptions import GraphQLNotAllowedException, GraphQLNotFoundException from apps.notifications.email import EmailNotification +from ..signals import membership_added_signal, membership_updated_signal from .commons import BaseAuthenticatedMutation, BaseDeleteMutation, TerrasoConnection from .constants import MutationTypes @@ -75,6 +76,7 @@ class Input: @classmethod def mutate_and_get_payload(cls, root, info, **kwargs): + request_user = info.context.user user_email = kwargs.pop("user_email") group_slug = kwargs.pop("group_slug") @@ -118,6 +120,8 @@ def mutate_and_get_payload(cls, root, info, **kwargs): membership.user_role = user_role membership.save() + membership_added_signal.send(sender=cls, membership=membership, user=request_user) + return cls(membership=membership) @@ -181,6 +185,8 @@ def mutate_and_get_payload(cls, root, info, **kwargs): membership.save() + membership_updated_signal.send(sender=cls, membership=membership, user=user) + return cls(membership=membership) diff --git a/terraso_backend/apps/graphql/schema/projects.py b/terraso_backend/apps/graphql/schema/projects.py index 05d697fa4..044545d4b 100644 --- a/terraso_backend/apps/graphql/schema/projects.py +++ b/terraso_backend/apps/graphql/schema/projects.py @@ -79,7 +79,11 @@ def mutate_and_get_payload(cls, root, info, **kwargs): if not client_time: client_time = datetime.now() action = log_api.CREATE - metadata = {"name": kwargs["name"], "privacy": kwargs["privacy"]} + metadata = { + "name": kwargs["name"], + "privacy": kwargs["privacy"], + "description": kwargs["description"] if "description" in kwargs else None, + } logger.log( user=user, action=action, @@ -159,10 +163,24 @@ class Input: @classmethod @transaction.atomic def mutate_and_get_payload(cls, root, info, **kwargs): + logger = cls.get_logger() user = info.context.user project_id = kwargs["id"] project = cls.get_or_throw(Project, "id", project_id) if not user.has_perm(Project.get_perm("change"), project): cls.not_allowed() kwargs["privacy"] = kwargs["privacy"].value - return super().mutate_and_get_payload(root, info, **kwargs) + result = super().mutate_and_get_payload(root, info, **kwargs) + metadata = { + "name": kwargs["name"], + "privacy": kwargs["privacy"], + "description": kwargs["description"] if "description" in kwargs else None, + } + logger.log( + user=user, + action=log_api.CHANGE, + resource=result.project, + client_time=datetime.now(), + metadata=metadata, + ) + return result diff --git a/terraso_backend/apps/graphql/schema/sites.py b/terraso_backend/apps/graphql/schema/sites.py index a41800e15..820fbe280 100644 --- a/terraso_backend/apps/graphql/schema/sites.py +++ b/terraso_backend/apps/graphql/schema/sites.py @@ -177,7 +177,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): continue metadata[key] = value if project_id: - metadata["project_name"] = project.name + metadata["project_id"] = str(project.id) log.log( user=user, diff --git a/terraso_backend/apps/graphql/signals.py b/terraso_backend/apps/graphql/signals.py new file mode 100644 index 000000000..c003f1a64 --- /dev/null +++ b/terraso_backend/apps/graphql/signals.py @@ -0,0 +1,19 @@ +# Copyright © 2023 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.dispatch import Signal + +membership_added_signal = Signal() +membership_updated_signal = Signal() diff --git a/terraso_backend/apps/project_management/apps.py b/terraso_backend/apps/project_management/apps.py index 94c0319d4..54e4c85bb 100644 --- a/terraso_backend/apps/project_management/apps.py +++ b/terraso_backend/apps/project_management/apps.py @@ -18,3 +18,6 @@ class ProjectManagementConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.project_management" + + def ready(self): + from .signals import handle_membership_added, handle_membership_updated # noqa diff --git a/terraso_backend/apps/project_management/signals.py b/terraso_backend/apps/project_management/signals.py new file mode 100644 index 000000000..472a2827e --- /dev/null +++ b/terraso_backend/apps/project_management/signals.py @@ -0,0 +1,62 @@ +# Copyright © 2023 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 datetime import datetime + +from django.dispatch import receiver + +from apps.audit_logs import api as audit_log_api +from apps.audit_logs import services +from apps.graphql.signals import membership_added_signal, membership_updated_signal + +audit_logger = services.new_audit_logger() + + +def _handle_membership_log(user, action, membership, client_time): + try: + project = membership.group.project + except Exception: + # No project for membership, do nothing + return + + audit_logger.log( + user=user, + action=action, + resource=membership, + metadata={ + "user_email": membership.user.email, + "user_role": membership.user_role, + "project_id": str(project.id), + }, + client_time=client_time, + ) + + +@receiver(membership_added_signal) +def handle_membership_added(sender, **kwargs): + membership = kwargs["membership"] + user = kwargs["user"] + client_time = datetime.now() + + _handle_membership_log(user, audit_log_api.CREATE, membership, client_time) + + +@receiver(membership_updated_signal) +def handle_membership_updated(sender, **kwargs): + membership = kwargs["membership"] + user = kwargs["user"] + client_time = datetime.now() + + _handle_membership_log(user, audit_log_api.CHANGE, membership, client_time) diff --git a/terraso_backend/tests/graphql/mutations/test_projects.py b/terraso_backend/tests/graphql/mutations/test_projects.py index 1228da2c3..ae1734823 100644 --- a/terraso_backend/tests/graphql/mutations/test_projects.py +++ b/terraso_backend/tests/graphql/mutations/test_projects.py @@ -4,7 +4,7 @@ from graphene_django.utils.testing import graphql_query from mixer.backend.django import mixer -from apps.audit_logs.api import CREATE +from apps.audit_logs.api import CHANGE, CREATE from apps.audit_logs.models import Log from apps.core.models.users import User from apps.project_management.models import Project @@ -42,16 +42,90 @@ def test_create_project(client, user): log_result = logs[0] assert log_result.event == CREATE.value assert log_result.resource_object == project - expected_metadata = {"name": "testProject", "privacy": "private"} + expected_metadata = { + "name": "testProject", + "privacy": "private", + "description": "A test project", + } assert log_result.metadata == expected_metadata +ADD_MEMBERSHIP_GRAPHQL = """ + mutation addUserToProject($input: MembershipAddMutationInput!) { + addMembership(input: $input) { + membership { + id + } + } + } +""" + + def test_add_user_to_project(client, project, project_manager, user): client.force_login(project_manager) + response = graphql_query( + ADD_MEMBERSHIP_GRAPHQL, + variables={ + "input": { + "userEmail": user.email, + "groupSlug": project.group.slug, + "userRole": "member", + } + }, + client=client, + ) + content = json.loads(response.content) + assert "errors" not in content and "errors" not in content["data"]["addMembership"] + assert project.is_member(user) + + +def test_add_user_to_project_audit_log(client, project, project_manager, user): + client.force_login(project_manager) + + assert project_manager.id != user.id + + response = graphql_query( + ADD_MEMBERSHIP_GRAPHQL, + variables={ + "input": { + "userEmail": user.email, + "groupSlug": project.group.slug, + "userRole": "member", + } + }, + client=client, + ) + + assert response.status_code == 200 + + membership = project.group.memberships.filter(user=user).first() + + logs = Log.objects.all() + assert len(logs) == 1 + log_result = logs[0] + assert log_result.event == CREATE.value + assert log_result.user_human_readable == project_manager.full_name() + assert log_result.resource_object == membership + expected_metadata = { + "user_email": user.email, + "user_role": "member", + "project_id": str(project.id), + } + assert log_result.metadata == expected_metadata + + +def test_update_user_to_project_audit_log(client, project, project_manager, user): + client.force_login(project_manager) + + assert project_manager.id != user.id + + project.group.add_member(user) + membership = project.group.memberships.filter(user=user).first() + response = graphql_query( """ - mutation addUserToProject($input: MembershipAddMutationInput!) { - addMembership(input: $input) { + mutation updateMembership($input: MembershipUpdateMutationInput!) { + updateMembership(input: $input) { membership { id } @@ -60,16 +134,29 @@ def test_add_user_to_project(client, project, project_manager, user): """, variables={ "input": { - "userEmail": user.email, - "groupSlug": project.group.slug, - "userRole": "member", + "id": str(membership.id), + "userRole": "manager", } }, client=client, ) - content = json.loads(response.content) - assert "errors" not in content and "errors" not in content["data"]["addMembership"] - assert project.is_member(user) + + assert response.status_code == 200 + + membership = project.group.memberships.filter(user=user).first() + + logs = Log.objects.all() + assert len(logs) == 1 + log_result = logs[0] + assert log_result.event == CHANGE.value + assert log_result.user_human_readable == project_manager.full_name() + assert log_result.resource_object == membership + expected_metadata = { + "user_email": user.email, + "user_role": "manager", + "project_id": str(project.id), + } + assert log_result.metadata == expected_metadata DELETE_PROJECT_GRAPHQL = """ @@ -185,6 +272,29 @@ def test_update_project_user_is_manager(project, client, project_manager): assert content["data"]["updateProject"]["project"]["privacy"] == "PRIVATE" +def test_update_project_audit_log(project, client, project_manager): + input = { + "id": str(project.id), + "name": "test_name", + "privacy": "PRIVATE", + "description": "A test project", + } + client.force_login(project_manager) + + response = graphql_query(UPDATE_PROJECT_GRAPHQL, input_data=input, client=client) + + assert response.status_code == 200 + + logs = Log.objects.all() + assert len(logs) == 1 + log_result = logs[0] + assert log_result.event == CHANGE.value + assert log_result.user_human_readable == project_manager.full_name() + assert log_result.resource_object == project + expected_metadata = {"name": "test_name", "privacy": "private", "description": "A test project"} + assert log_result.metadata == expected_metadata + + def test_update_project_user_not_manager(project, client): user = mixer.blend(User) project.add_member(user) diff --git a/terraso_backend/tests/graphql/mutations/test_sites.py b/terraso_backend/tests/graphql/mutations/test_sites.py index 08b1a291b..997ee9d1f 100644 --- a/terraso_backend/tests/graphql/mutations/test_sites.py +++ b/terraso_backend/tests/graphql/mutations/test_sites.py @@ -129,7 +129,7 @@ def test_update_site_in_project(client, project, project_manager, site): log_result = logs[0] assert log_result.event == CHANGE.value assert log_result.resource_object == site - assert log_result.metadata["project_name"] == project.name + assert log_result.metadata["project_id"] == str(project.id) def test_adding_site_to_project_user_not_manager(client, project, site, user):